From adea1f0d99ea30164b290101d47f25f61376c75e Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 9 Apr 2026 23:04:08 -0700 Subject: [PATCH 1/6] Added a sort feature --- ChangeLog.md | 120 +++++++++------ faststack/app.py | 59 ++++++++ faststack/qml/Main.qml | 74 +++++++++ faststack/tests/test_sort_mode.py | 243 ++++++++++++++++++++++++++++++ faststack/ui/provider.py | 6 + 5 files changed, 458 insertions(+), 44 deletions(-) create mode 100644 faststack/tests/test_sort_mode.py diff --git a/ChangeLog.md b/ChangeLog.md index 052ef80..6dafab3 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,6 +1,6 @@ # ChangeLog -Todo: More testing Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. Fix raw image support. +Todo: More testing Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. Fix raw image support. ## 1.6.2 (2026-03-28) @@ -16,6 +16,7 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Added "Darken Background (K)" button in the Image Editor effects section. - J and K keys no longer navigate to next/previous image. Use arrow keys instead. - K key now opens the Background Darkening tool (works from loupe view or inside the editor). +- Added a feature to sort the photos. ## 1.6.1 (2026-03-13) @@ -25,15 +26,15 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Moved menu activation from hovering over the image to hovering over the title bar. - Expand the default prefetch window from a radius of 4 to 12 images. - Introduce directional awareness to task cancellation logic, making the prefetcher a lot faster. -- Improved TurboJPEG setup on Windows by using shared library detection logic in JPEG decoding and thumbnail prefetching. Thanks to Andy Arijs for the PR! -- Added Windows documentation for installing turbojpeg.dll, using FASTSTACK_TURBOJPEG_LIB, and understanding fallback behavior. Thanks to Andy Arijs! -- FastStack now more clearly explains when it falls back to Pillow for JPEG decoding and thumbnails. Thanks to Andy Arijs! +- Improved TurboJPEG setup on Windows by using shared library detection logic in JPEG decoding and thumbnail prefetching. Thanks to Andy Arijs for the PR! +- Added Windows documentation for installing turbojpeg.dll, using FASTSTACK_TURBOJPEG_LIB, and understanding fallback behavior. Thanks to Andy Arijs! +- FastStack now more clearly explains when it falls back to Pillow for JPEG decoding and thumbnails. Thanks to Andy Arijs! - Recycle bin restore is now per-directory: each bin shows its destination, file counts, and an independent Restore button - Bins with legacy files that cannot be auto-restored are clearly labeled instead of silently ignored - Restore feedback reports skipped files and legacy remainders - RAW decode failures now show a distinct "Preview unavailable" placeholder instead of a plain dark image -## 1.6.0 (2026-03-06) +## 1.6.0 (2026-03-06) - Added a "Todo" flag: toggle with D, filterable in Filter dialog, shown on thumbnails (badge, tile visuals, red on sparkline), and displayed as "Todo since {date}" in the UI. - Fixed batch range alignment after deletions to prevent stale/misaligned UI state. @@ -52,7 +53,7 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document ## 1.5.9 (2026-02-16) -- Full-Screen Mode: Press F11 to toggle fullscreen in loupe view +- Full-Screen Mode: Press F11 to toggle fullscreen in loupe view - Spark Line Display: Grid view now shows upload progress indicators per folder. - Optimized grid view performance and prefetch behavior. - Added EXIF brief display in status bar showing ISO, aperture, shutter speed, and capture time. @@ -94,24 +95,25 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Folder refreshes from filesystem changes are now debounced (grouped together), so you get fewer slow rescans during saves. - Backup images (`*-backup.jpg`, `*-backup2.jpg`, etc.) are no longer shown in the image list. - ## 1.5.6 (2026-02-08) ### Performance + - Debounced `metadataChanged` / `highlightStateChanged` emissions to reduce UI overhead during rapid navigation. - Increased default prefetch radius to **6** and raised prefetch worker cap to **8** for smoother fast navigation. - Added optional `[DBGCACHE]` timing logs for image request/decode and UI refresh paths when `debug_cache` is enabled. ### Stability + - Refactored shutdown into `shutdown_qt()` (main thread) and `shutdown_nonqt()` (background-safe), wired from `aboutToQuit` in `main()` with a timeout/stacks fallback to diagnose hangs. - Added cooperative cancellation and `cancel_futures=True` shutdown behavior to both main image and thumbnail prefetchers. - Ensured thumbnail “ready” callbacks run on the Qt thread when available; hardened cancellation/callback ordering to avoid deadlocks and worker-thread Qt warnings. - Enabled Ctrl-C termination via SIGINT handling and a periodic Qt timer to allow Python signal processing. - ## 1.5.5 (2026-02-07) ### Changed + - Image save behavior in the editor is now navigation-aware: - Only clear editor state / close editor UI when the user is still on the same image. - Only perform a full list refresh + re-select logic when the user is still on the same image. @@ -130,6 +132,7 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Rebuild the path-to-index map after operations that mutate the image list, including after recycle/rollback flows. ### Fixed + - Rotation/autocrop and straighten edge handling: - Use `floor()` instead of `round()` in inscribed-rectangle and crop coordinate math to reduce off-by-one drift. - Skip inset trimming for exact 90° rotations to preserve full dimensions and avoid unnecessary cropping. @@ -137,6 +140,7 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document ## 1.5.4 (2026-02-04) ### Fixed + - Image rotation fixed - no more black wedges on the edges of the image. - Prevented “undo delete” from resurrecting files when recycle/rollback fails: if a JPG can’t be restored after a partial delete, it’s marked as deleted (`jpg_moved=True`), a warning is shown, and a `recycled_jpg_path` breadcrumb is stored for potential cleanup. - Improved crop behavior when straightening/rotating with `expand=True` by transforming crop coordinates from original image space into the expanded canvas. @@ -146,6 +150,7 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Improved Escape key reliability during crop/rotation by explicitly re-focusing the loupe view. ### Changed + - Refactored deletion into a unified core deletion engine (`_delete_indices`) shared by loupe, grid cursor, grid selection, and batch deletion paths. - Deletion now uses an optimistic UI update for instant feedback, with deferred/coalesced disk refresh to avoid flicker and “deleted items reappear” issues. - Grid deletion now supports multi-selection and cursor deletion through a single entry point, rebuilding the path→index mapping for reliable lookup. @@ -156,13 +161,14 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Replaced the simple message dialog with a richer dialog showing per-bin counts (JPG/RAW/other) and an optional detailed file list. ### UI + - Resized the Image Editor dialog to accommodate the saving state/controls. - Enhanced recycle bin cleanup dialog layout and interaction (expandable detailed list, clearer button actions). - ## 1.5.3 (2026-01-27) ### Added + - New **Thumbnail Grid View** (folder-style browser) with a fast thumbnail pipeline: - `ThumbnailModel`, `ThumbnailProvider`, `ThumbnailPrefetcher`, `ThumbnailCache`, and `PathResolver` integrated into `AppController`. - App now defaults to starting in grid view (thumbnail mode) and initializes the model/resolver on startup. @@ -173,6 +179,7 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Grid-mode status bar controls for selection count, clear selection, refresh, and quick return to single image. ### Changed + - Implemented grid/loupe view switching using a `StackLayout` in `Main.qml` to keep both views loaded and preserve state while toggling. - Improved grid-to-loupe opening performance by adding an O(1) resolved-path → index map (`_path_to_index`) for quick lookup when opening an image from the grid. - Directory changes now refresh thumbnail infrastructure: @@ -181,15 +188,16 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Grid selection count is now exposed efficiently to QML via `uiState.gridSelectedCount` (avoids copying full selected-path lists just to display counts). ### Fixed + - Thread-safety for thumbnail completion callbacks: - Thumbnail decode completion now hops to the GUI thread via an internal signal (`_thumbnailReadySignal`) using an explicit `Qt.QueuedConnection`. - Added guards to avoid model updates during/after shutdown. - Added shutdown safety for thumbnail prefetcher (guard against partial initialization). - ## 1.5.2 (2026-01-25) ### Added + - **Highlight recovery telemetry + UI indicators** - New highlight state analysis in the editor pipeline (headroom/clipping/near-white metrics) and a UI signal (`highlightStateChanged`) to keep it live. - Image editor dialog now shows **Headroom** and **Clipped** indicators under the histogram when relevant. @@ -201,6 +209,7 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Tests updated to skip/patch appropriately when OpenCV isn’t installed. ### Changed + - **Save flow restored to “old behavior”** - Saving now: **closes editor → clears editor state → refreshes image list → reselects saved image → clears cache/prefetches → syncs UI**. - Save errors now surface as user-visible status messages with safer exception handling. @@ -215,6 +224,7 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Auto-levels now kicks the preview worker for immediate visual feedback; histogram updates are guarded by visibility in more places. ### Fixed + - **EXIF orientation “double rotation” bugs** - Saving now consistently drops/sanitizes EXIF when orientation can’t be safely serialized, preventing incorrect viewer rotations. - Developed JPG sidecar EXIF from a paired JPEG is sanitized for Orientation as well. @@ -223,10 +233,10 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - **RawTherapee Windows path detection** - Improved version selection by sorting detected installs via a version-aware path component key (e.g., `5.10` > `5.9`). - ## [1.5.1] - 2026-01-23 ### Added + - Added experimental RAW processing via Rawtherapee - Added explicit **JPEG vs RAW editing modes** with UI + signals to keep QML and backend in sync (`editSourceModeChanged`, `saveBehaviorMessage`). RAW mode can develop to a 16-bit working TIFF and optionally write a `*-developed.jpg` output while leaving the original JPEG untouched. - Added **RAW development workflow** via RawTherapee **CLI** (`rawtherapee-cli`) with configurable extra args, better error reporting, output validation, and timeout handling. @@ -236,6 +246,7 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Added support for indexing and displaying `*-developed.jpg` images and **orphaned RAWs** in the browser list; updated pairing test expectations accordingly. ### Changed + - Reworked README installation instructions: - macOS recommended flow with **Python 3.12** (Homebrew) + venv + `pip install .` - Simplified run command (`faststack`) and clarified Windows/Linux steps. @@ -244,20 +255,19 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Centralized navigation state changes (`_set_current_index`) and ensured edit mode resets appropriately on navigation (defaults back to JPEG unless RAW-only). ### Fixed + - Fixed editor memory usage by clearing large editor buffers when the editor closes and resetting cached preview state. - Fixed a QML slider double-click reset edge case where the slider could remain in a pressed/dragging state (force release via a short disable/reenable tick). - Fixed histogram scheduling/thread-safety issues by tightening locking around pending/inflight state and improving failure handling when preview data is missing or executor submission fails. - - ## [1.5.0] - 2025-12-01 - Fixed rotating images via the crop interface. -- Control-1 zooms to 1:1 magnification (100%). Control-2 to 200, etc to control-4 (400%). +- Control-1 zooms to 1:1 magnification (100%). Control-2 to 200, etc to control-4 (400%). ## [1.4.0] - 2025-12-01 -- Changed how image caching works for even faster display. +- Changed how image caching works for even faster display. - Pressing H brings up a RGB histogram which is designed to show even a little bit of highlight clipping and updates as you zoom in. - Added batch delete with confirmation dialog. - Added the --cachedebug command line argument which gives info on the image cache in the status bar. Doesn't seem to slow down the program at all, just takes up room in the status bar.- Added a setting that switches between image display optimized for speed or quality. @@ -268,12 +278,11 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document ## [1.3.0] - 2025-11-23 -- Added the ability to crop images, via the cr(O)p hotkey. It can be a freeform crop, or constrained to several popular aspect ratios. +- Added the ability to crop images, via the cr(O)p hotkey. It can be a freeform crop, or constrained to several popular aspect ratios. - Sorts images by time. - Added the Stack Source Raws feature in the Action menu - if you import your images with stackcopy.py --lightroomimport (https://github.com/AlanRockefeller/faststack) and you are viewing a photo stacked in-camera, this feature will open the raw images that made this stack in Helicon Focus. - Some fixes to the image cache - it doesn't expire when it shouldn't, does expire when it should, and warns you when the cache is full so you can consider increasing the cache size in settings. - ## [1.2.0] - 2025-11-22 - Fixed menus, they now work well and look cool. @@ -282,6 +291,7 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document ## [1.1.0] - 2025-11-22 ### Major Features + - **Built-in Image Editor:** Full-featured image editor with draggable window - Exposure, highlights, shadows, whites, blacks, brightness, contrast - White balance (Blue/Yellow and Magenta/Green axes) @@ -302,13 +312,15 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - `B` key toggles images in/out of batch selection ### UI/UX Improvements + - **Updated Key Bindings Dialog:** Added documentation for new features - Auto white balance (A key) - - Image editor toggle (E key) + - Image editor toggle (E key) ## [1.0.0] - 2025-11-21 ### Major Features + - **Batch Selection System:** New batch selection mode for drag-and-drop operations - `{` to begin batch, `}` to end batch, `\` to clear all batches - `X` or `S` keys remove individual images from batches/stacks (shrinks or splits ranges) @@ -316,7 +328,7 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Multiple files can now be dragged to browsers and external applications simultaneously - **Manual Flag Toggles:** Added keyboard shortcuts to manually control metadata flags - `U` toggles uploaded flag - - `Ctrl+E` toggles edited flag + - `Ctrl+E` toggles edited flag - `Ctrl+S` toggles stacked flag - **Edited Flag Tracking:** New metadata flag for images edited in Photoshop - Displays "Edited on [date]" in status bar (green) @@ -326,19 +338,22 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Proper keyboard event capture while dialog is open ### UI/UX Improvements + - **Auto Zoom Reset:** Image view automatically resets to fit-window after drag operations - **Smooth Window Dragging:** Fixed flickering when dragging title bar by using global coordinates -- **Status Bar Enhancements:** +- **Status Bar Enhancements:** - Added batch info display (green badge showing position/count) - Added uploaded status display - Added edited status display ### Bug Fixes + - **Multi-file Drag:** Simplified drag implementation to work correctly with Chrome and other browsers ## [0.9.0] - 2025-11-20 ### Performance Improvements + - **Zero-Copy JPEG Read:** Eliminated memory copy by passing mmap directly to decoders, reducing I/O time by 25-60% for large JPEGs. - **Filter Performance:** Cached image list in memory to eliminate disk scans on every filter keystroke (100-1000x faster for large directories). - **Smart Cache Management:** Removed unnecessary cache clearing on resize/zoom - LRU naturally evicts old entries while allowing instant reuse. @@ -348,70 +363,81 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - **TurboJPEG for ICC:** ICC color path now uses TurboJPEG for decode+resize, then Pillow only for color conversion. ### Features + - **JPG Fallback for Helicon:** Helicon Focus stacking now works with JPG-only workflows when RAW files absent. - **Comprehensive Timing Instrumentation:** Added detailed decode timing logs in debug mode for performance analysis.- **Jump to Photo:** Press `G` to jump directly to any image (feature documented more fully in [1.0.0]). - **Comprehensive Timing Instrumentation:** Added detailed decode timing logs in debug mode for performance analysis. - **Jump to Photo:** Press `G` to jump directly to any image (feature documented more fully in [1.0.0]). -## [0.8.0] - 2025-11-20- Backspace key now deletes images (in addition to Delete key). Control-Z restores. +## [0.8.0] - 2025-11-20- Backspace key now deletes images (in addition to Delete key). Control-Z restores. + - Photoshop integration now automatically uses RAW files when available, falling back to JPG. -- We now have some new color modes in the view menu to make the images in your monitor reflect reality. ICC profile mode works best on my system - try it if the images are over-saturated - or turn down the saturation in saturation mode. Test it out by loading an image in Faststack and Photoshop or another image viewer and make sure the colors look the same. +- We now have some new color modes in the view menu to make the images in your monitor reflect reality. ICC profile mode works best on my system - try it if the images are over-saturated - or turn down the saturation in saturation mode. Test it out by loading an image in Faststack and Photoshop or another image viewer and make sure the colors look the same. ## [0.7.0] - 2025-11-20 ### Added + - **High-DPI Display Support:** Images now render at full physical pixel resolution on 4K displays by accounting for `devicePixelRatio` in display size calculations. - **Ctrl+0 Zoom Reset:** Added keyboard shortcut to reset zoom and pan to fit window (like Photoshop), with visual feedback. - **Active Filter Indicator:** Footer now displays active filename filter in yellow bold text for better visibility. - **Directory Path Display:** Title bar now shows the current working directory path, centered between menu and window controls. ### Fixed + - **Property Name Mismatch:** Corrected `get_stack_summary` to `stackSummary` in UIState to match QML property naming conventions. - **FilterDialog Theme Support:** Enhanced FilterDialog with proper Material theme support and background styling for consistent dark/light mode appearance. - **Missing Signal Emissions:** Added `stackSummaryChanged` signal emission when stacks are created, cleared, or processed. ### Changed + - **Improved Error Handling:** Replaced broad `Exception` catches with specific exception types (`OSError`, `subprocess.SubprocessError`, `FileNotFoundError`, `IOError`, `PermissionError`). - **Better Logging:** Changed `log.error()` to `log.exception()` to include full tracebacks for debugging. - **Argument Parsing:** Now uses `shlex.split()` with platform-aware parsing (Windows vs POSIX) for proper handling of quoted paths and special characters. ### Testing + - **Executable Validator Tests:** Added comprehensive test suite for executable path validation with 8 test cases covering various security scenarios. ## [0.6.0] - 2025-11-03 ### Fixed -- Resolved an issue where the prefetch range was not being applied correctly after changing the prefetch radius in settings. -- Corrected `decode_jpeg_thumb_rgb` to ensure that thumbnails generated by PyTurboJPEG do not exceed the `max_dim` by falling back to Pillow resizing when necessary. -- Addressed excessive metadata queries during application startup by deferring UI synchronization until after images are loaded. -- Fixed a bug where the zoom state callback was not firing, leading to low-resolution images being served when zoomed in. -- Resolved a QML error "Cannot assign to non-existent property 'scaleTransform'" by correctly placing the scale change handlers within the `Scale` transform. -- Handled the empty image files case in preloading to prevent unnecessary processing and correctly update the UI. + +- Resolved an issue where the prefetch range was not being applied correctly after changing the prefetch radius in settings. +- Corrected `decode_jpeg_thumb_rgb` to ensure that thumbnails generated by PyTurboJPEG do not exceed the `max_dim` by falling back to Pillow resizing when necessary. +- Addressed excessive metadata queries during application startup by deferring UI synchronization until after images are loaded. +- Fixed a bug where the zoom state callback was not firing, leading to low-resolution images being served when zoomed in. +- Resolved a QML error "Cannot assign to non-existent property 'scaleTransform'" by correctly placing the scale change handlers within the `Scale` transform. +- Handled the empty image files case in preloading to prevent unnecessary processing and correctly update the UI. ## [0.5.0] - 2025-11-03 ### Added -- Load full-resolution images when zooming in for maximum detail. -- Call Helicon Focus for each defined stack when multiple stacks are present. + +- Load full-resolution images when zooming in for maximum detail. +- Call Helicon Focus for each defined stack when multiple stacks are present. ### Changed -- The filesystem watcher is now less sensitive to spurious modification events, reducing unnecessary refreshes. -- The preloading process now shares the same thread pool as the prefetcher for better resource utilization. -- Stacks are now cleared automatically after being sent to Helicon Focus. + +- The filesystem watcher is now less sensitive to spurious modification events, reducing unnecessary refreshes. +- The preloading process now shares the same thread pool as the prefetcher for better resource utilization. +- Stacks are now cleared automatically after being sent to Helicon Focus. ### Fixed -- Corrected a `ValueError` in `PyTurboJPEG` caused by unsupported scaling factors. -- Resolved an `AttributeError` in the JPEG scaling factor calculation. -- Fixed an issue where panning the image was not working correctly. -- Addressed a bug where panning speed was incorrect at high zoom levels. -- Ensured that stale prefetcher futures are cancelled when the display size changes. + +- Corrected a `ValueError` in `PyTurboJPEG` caused by unsupported scaling factors. +- Resolved an `AttributeError` in the JPEG scaling factor calculation. +- Fixed an issue where panning the image was not working correctly. +- Addressed a bug where panning speed was incorrect at high zoom levels. +- Ensured that stale prefetcher futures are cancelled when the display size changes. ### Performance -- Improved image decoding performance by using `PyTurboJPEG` for resized decoding. -- Tuned the number of prefetcher thread pool workers based on system CPU cores. -- Replaced synchronous file reads with memory-mapped I/O for faster image loading. -- Optimized image resizing by using `BILINEAR` resampling for large downscales. -- Debounced display size change notifications to reduce redundant UI updates. + +- Improved image decoding performance by using `PyTurboJPEG` for resized decoding. +- Tuned the number of prefetcher thread pool workers based on system CPU cores. +- Replaced synchronous file reads with memory-mapped I/O for faster image loading. +- Optimized image resizing by using `BILINEAR` resampling for large downscales. +- Debounced display size change notifications to reduce redundant UI updates. ## Version 0.4 @@ -420,20 +446,23 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document Make it use the full res image when zooming in When multiple stacks are selected, call Helicon multiple times After Helicon is called, clear the stacks -Fix S key - I guess it should remove an image from the stack? Clarify what it does now. +Fix S key - I guess it should remove an image from the stack? Clarify what it does now. ### New Features + - **Two-tier caching system:** Implemented a two-tier caching system to prefetch display-sized images, significantly improving performance and reducing GPU memory usage. - **"Preload All Images" feature:** Added a new menu option under "Actions" to preload all images in the current directory into the cache, ensuring quick access even for unviewed images. - **Progress bar for preloading:** Introduced a visual progress bar in the footer to display the status of the "Preload All Images" operation. ### Changes + - **Theming improvements:** Adjusted the Material theme to ensure the menubar background is black in dark mode, providing a more consistent user experience. - **Window behavior:** Changed the application window to a borderless fullscreen mode, allowing for normal Alt-Tab behavior and better integration with the operating system. ## Version 0.3 ### New Features + - Implemented a "Settings" dialog with the following configurable options: - Helicon Focus executable path (with validation). - Image cache size (in GB). @@ -444,6 +473,7 @@ Fix S key - I guess it should remove an image from the stack? Clarify what it ## Version 0.2 ### New Features + - Added an "Actions" menu with the following options: - "Run Stacks": Launch Helicon Focus with selected files or all stacks. - "Clear Stacks": Clear all defined stacks. @@ -455,10 +485,12 @@ Fix S key - I guess it should remove an image from the stack? Clarify what it - The footer in `Main.qml` displays "Stacked: [date]" for previously stacked images. ### Changes + - Pressing the 'Enter' key will now launch Helicon Focus with the selected RAW files. If no files are selected, it will launch with all defined stacks. - Refactored the theme toggling logic in `Main.qml` to use a boolean `isDarkTheme` property for more robustness. ### Bug Fixes + - Fixed an issue where both the main "Enter" key and the numeric keypad "Enter" key were not consistently recognized. - The "Show Stacks" and "Key Bindings" dialogs now correctly follow the application's theme (light/dark mode). - Fixed a bug that caused the "Show Stacks" dialog to be blank. diff --git a/faststack/app.py b/faststack/app.py index 3b41459..ffedee2 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -415,6 +415,7 @@ def __init__( [] ) # Active flag filters (e.g. ["uploaded", "stacked"]) self._filter_enabled: bool = False + self.sort_mode: str = "default" self._metadata_cache = {} self._metadata_cache_index = (-1, -1) @@ -680,6 +681,54 @@ def clear_filter(self): self.sync_ui_state() self._do_prefetch(self.current_index) + @Slot(result=str) + def get_sort_mode(self): + return self.sort_mode + + @Slot(str) + def set_sort_mode(self, mode: str): + if mode not in ("default", "filename", "date"): + return + if self.sort_mode == mode: + return + self.sort_mode = mode + + preserved_path = None + if self.image_files and 0 <= self.current_index < len(self.image_files): + preserved_path = self.image_files[self.current_index].path + + self._apply_filter_to_cached_list() + self._bump_display_generation() + + if self.image_files and preserved_path: + target_key = self._key(preserved_path) + new_idx = self._path_to_index.get(target_key) + if new_idx is not None: + self.current_index = new_idx + else: + self._clear_variant_override() + self.current_index = 0 + else: + self._clear_variant_override() + self.current_index = 0 + + if self._is_grid_view_active: + self._thumbnail_prefetcher.cancel_all() + + if self._is_grid_view_active and self._thumbnail_model: + self._grid_refreshes += 1 + self._thumbnail_model.refresh_from_controller(self.image_files) + self._path_resolver.update_from_model(self._thumbnail_model) + self._grid_model_dirty = False + else: + self._grid_model_dirty = True + + self.sync_ui_state() + self._do_prefetch(self.current_index) + self.dataChanged.emit() + if hasattr(self, "ui_state") and self.ui_state: + self.ui_state.sortModeChanged.emit() + def get_display_info(self): with self._display_lock: if self.is_zoomed: @@ -1085,6 +1134,16 @@ def _apply_filter_to_cached_list(self): result.append(img) filtered = result + if self.sort_mode == "filename": + filtered.sort(key=lambda img: img.path.name.lower()) + elif self.sort_mode == "date": + # Use the timestamp captured at scan time (ImageFile.timestamp) + # so we avoid live filesystem calls during sort. Tiebreak on + # lowercase filename for determinism when mtimes are equal. + filtered.sort( + key=lambda img: (-img.timestamp, img.path.name.lower()) + ) + self.image_files = filtered self._rebuild_path_to_index() self.prefetcher.set_image_files(self.image_files) diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 10df6d4..4d1a283 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -901,6 +901,80 @@ ApplicationWindow { } } + // Separator before Sort options + Rectangle { + width: 220 + height: 1 + color: root.isDarkTheme ? "#666666" : "#cccccc" + } + + ItemDelegate { + width: 220 + height: 36 + text: "Sort: Default" + onClicked: { + if (controller) controller.set_sort_mode("default") + actionsMenu.close() + } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") + : ((uiState && uiState.sortMode === "default") + ? (root.isDarkTheme ? "#505050" : "#d0ffd0") + : "transparent") + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + font.bold: uiState && uiState.sortMode === "default" + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + ItemDelegate { + width: 220 + height: 36 + text: "Sort: By Filename" + onClicked: { + if (controller) controller.set_sort_mode("filename") + actionsMenu.close() + } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") + : ((uiState && uiState.sortMode === "filename") + ? (root.isDarkTheme ? "#505050" : "#d0ffd0") + : "transparent") + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + font.bold: uiState && uiState.sortMode === "filename" + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + ItemDelegate { + width: 220 + height: 36 + text: "Sort: By Date (Newest)" + onClicked: { + if (controller) controller.set_sort_mode("date") + actionsMenu.close() + } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") + : ((uiState && uiState.sortMode === "date") + ? (root.isDarkTheme ? "#505050" : "#d0ffd0") + : "transparent") + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + font.bold: uiState && uiState.sortMode === "date" + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + // Clear Filename Filter (from old Main.qml) ItemDelegate { width: 220 diff --git a/faststack/tests/test_sort_mode.py b/faststack/tests/test_sort_mode.py new file mode 100644 index 0000000..2fc970d --- /dev/null +++ b/faststack/tests/test_sort_mode.py @@ -0,0 +1,243 @@ +"""Tests for sort-mode feature (default / filename / date).""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from faststack.models import ImageFile + + +@pytest.fixture +def app_controller(tmp_path): + from PySide6.QtCore import QCoreApplication + + from faststack.app import AppController + + app = QCoreApplication.instance() + if not app: + app = QCoreApplication([]) + + image_dir = tmp_path / "images" + image_dir.mkdir() + + mock_engine = MagicMock() + + with ( + patch("faststack.app.Watcher"), + patch("faststack.app.SidecarManager"), + patch("faststack.app.Prefetcher"), + patch("faststack.app.ByteLRUCache"), + patch("faststack.app.config"), + patch("faststack.app.ThumbnailProvider"), + patch("faststack.app.ThumbnailModel"), + patch("faststack.app.ThumbnailPrefetcher"), + patch("faststack.app.ThumbnailCache"), + patch("faststack.app.Keybinder"), + patch("faststack.app.UIState"), + ): + controller = AppController(image_dir, mock_engine, debug_cache=False) + controller.refresh_image_list = MagicMock() + controller.update_status_message = MagicMock() + controller.sync_ui_state = MagicMock() + controller.image_cache = MagicMock() + controller.prefetcher = MagicMock() + controller._thumbnail_model = MagicMock() + controller._thumbnail_model.rowCount.return_value = 0 + controller._thumbnail_prefetcher = MagicMock() + controller._path_resolver = MagicMock() + controller.dataChanged = MagicMock() + controller.ui_state = MagicMock() + return controller + + +def _make_images(tmp_path, specs): + """Create ImageFile objects from (name, timestamp) pairs. + + Files are touched on disk so that Path objects are valid, but sorting + uses the ImageFile.timestamp field, not live stat() calls. + """ + imgs = [] + for name, ts in specs: + p = tmp_path / name + p.write_bytes(b"\xff\xd8") # minimal JPEG header + imgs.append(ImageFile(path=p, timestamp=ts)) + return imgs + + +def _populate(ctrl, images): + """Set up controller with a list of ImageFile objects.""" + ctrl._all_images = list(images) + ctrl.image_files = list(images) + ctrl._rebuild_path_to_index() + ctrl.current_index = 0 + + +# --- sort mode preserves current image --- + + +def test_sort_mode_preserves_current_image(app_controller, tmp_path): + """Changing sort mode keeps the selected image when it remains present.""" + imgs = _make_images( + tmp_path, + [("a.jpg", 1000), ("b.jpg", 3000), ("c.jpg", 2000)], + ) + _populate(app_controller, imgs) + # Select b.jpg (index 1 in default order) + app_controller.current_index = 1 + + app_controller.set_sort_mode("date") + + # b.jpg has the highest timestamp so it moves to index 0 + assert app_controller.image_files[app_controller.current_index].path.name == "b.jpg" + + +# --- empty image list does not crash --- + + +def test_sort_mode_empty_image_list(app_controller): + """Switching sort mode with no images must not raise.""" + app_controller._all_images = [] + app_controller.image_files = [] + app_controller._rebuild_path_to_index() + + app_controller.set_sort_mode("filename") + assert app_controller.image_files == [] + assert app_controller.current_index == 0 + + +def test_sort_mode_empty_filtered_result(app_controller, tmp_path): + """Sort mode change with active filter that matches nothing must not crash.""" + imgs = _make_images(tmp_path, [("photo.jpg", 1000)]) + _populate(app_controller, imgs) + + # Enable a filter that matches nothing + app_controller._filter_enabled = True + app_controller._filter_string = "zzz_no_match" + + app_controller.set_sort_mode("date") + assert app_controller.image_files == [] + assert app_controller.current_index == 0 + + +# --- date sort uses ImageFile.timestamp, not live stat() --- + + +def test_date_sort_uses_timestamp_field(app_controller, tmp_path): + """Date sort must use ImageFile.timestamp, not filesystem stat().""" + imgs = _make_images( + tmp_path, + [("old.jpg", 1000), ("mid.jpg", 2000), ("new.jpg", 3000)], + ) + _populate(app_controller, imgs) + + app_controller.set_sort_mode("date") + + names = [img.path.name for img in app_controller.image_files] + assert names == ["new.jpg", "mid.jpg", "old.jpg"] + + +def test_date_sort_oserror_does_not_crash(app_controller, tmp_path): + """An image whose file was deleted between scan and sort must not crash. + + Since we use ImageFile.timestamp (captured at scan time), there's no + filesystem call to fail. Images with timestamp 0 sort last. + """ + p_missing = tmp_path / "gone.jpg" + p_missing.write_bytes(b"\xff\xd8") + imgs = [ + ImageFile(path=p_missing, timestamp=0.0), # simulates missing/failed stat + ImageFile(path=tmp_path / "ok.jpg", timestamp=5000), + ] + (tmp_path / "ok.jpg").write_bytes(b"\xff\xd8") + _populate(app_controller, imgs) + + app_controller.set_sort_mode("date") + + names = [img.path.name for img in app_controller.image_files] + # ok.jpg (ts=5000) first, gone.jpg (ts=0) last + assert names == ["ok.jpg", "gone.jpg"] + + +# --- date sort determinism with equal mtimes --- + + +def test_date_sort_deterministic_equal_mtimes(app_controller, tmp_path): + """When timestamps are equal, sort must be deterministic by filename.""" + imgs = _make_images( + tmp_path, + [("charlie.jpg", 5000), ("alpha.jpg", 5000), ("bravo.jpg", 5000)], + ) + _populate(app_controller, imgs) + + app_controller.set_sort_mode("date") + + names = [img.path.name for img in app_controller.image_files] + # Same timestamp → alphabetical tiebreak + assert names == ["alpha.jpg", "bravo.jpg", "charlie.jpg"] + + +# --- filename sort --- + + +def test_filename_sort(app_controller, tmp_path): + imgs = _make_images( + tmp_path, + [("Zebra.jpg", 1000), ("apple.jpg", 2000), ("Mango.jpg", 3000)], + ) + _populate(app_controller, imgs) + + app_controller.set_sort_mode("filename") + + names = [img.path.name for img in app_controller.image_files] + assert names == ["apple.jpg", "Mango.jpg", "Zebra.jpg"] + + +# --- grid model refresh after sort --- + + +def test_grid_model_refreshed_when_grid_active(app_controller, tmp_path): + """Grid model must be refreshed when sort mode changes in grid view.""" + imgs = _make_images(tmp_path, [("a.jpg", 1000), ("b.jpg", 2000)]) + _populate(app_controller, imgs) + app_controller._is_grid_view_active = True + + app_controller.set_sort_mode("filename") + + app_controller._thumbnail_model.refresh_from_controller.assert_called_once() + assert app_controller._grid_model_dirty is False + + +def test_grid_model_dirty_when_grid_inactive(app_controller, tmp_path): + """Grid model dirty flag must be set when sort changes outside grid view.""" + imgs = _make_images(tmp_path, [("a.jpg", 1000), ("b.jpg", 2000)]) + _populate(app_controller, imgs) + app_controller._is_grid_view_active = False + + app_controller.set_sort_mode("filename") + + app_controller._thumbnail_model.refresh_from_controller.assert_not_called() + assert app_controller._grid_model_dirty is True + + +# --- no-op when mode unchanged --- + + +def test_set_sort_mode_noop_when_unchanged(app_controller, tmp_path): + """Setting the same sort mode should be a no-op.""" + imgs = _make_images(tmp_path, [("a.jpg", 1000)]) + _populate(app_controller, imgs) + + app_controller.set_sort_mode("default") # already default + + # sync_ui_state should not be called again (the fixture's mock tracks calls) + app_controller.sync_ui_state.assert_not_called() + + +# --- invalid sort mode ignored --- + + +def test_set_sort_mode_invalid_ignored(app_controller): + """Invalid sort mode string must be silently ignored.""" + app_controller.set_sort_mode("random") + assert app_controller.sort_mode == "default" diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index d31ec79..691fbab 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -189,6 +189,7 @@ class UIState(QObject): stackSummaryChanged = Signal() # Signal for stack summary updates filterStringChanged = Signal() # Signal for filter string updates colorModeChanged = Signal() # Signal for color mode updates + sortModeChanged = Signal() # Signal for sort mode updates saturationFactorChanged = Signal() # Signal for saturation factor updates awbModeChanged = Signal() awbStrengthChanged = Signal() @@ -642,6 +643,11 @@ def colorMode(self): """Returns the current color mode.""" return self.app_controller.get_color_mode() + @Property(str, notify=sortModeChanged) + def sortMode(self): + """Returns the current sort mode.""" + return self.app_controller.get_sort_mode() + @Property(float, notify=saturationFactorChanged) def saturationFactor(self): """Returns the current saturation factor.""" From 78c7f895fb8a15c0318ccf5beedfbc223d1d5746 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 9 Apr 2026 23:04:47 -0700 Subject: [PATCH 2/6] format with black --- faststack/app.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index ffedee2..466b705 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -1140,9 +1140,7 @@ def _apply_filter_to_cached_list(self): # Use the timestamp captured at scan time (ImageFile.timestamp) # so we avoid live filesystem calls during sort. Tiebreak on # lowercase filename for determinism when mtimes are equal. - filtered.sort( - key=lambda img: (-img.timestamp, img.path.name.lower()) - ) + filtered.sort(key=lambda img: (-img.timestamp, img.path.name.lower())) self.image_files = filtered self._rebuild_path_to_index() From da329b49f71136665deb5b53c827d62bb27f9c68 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Fri, 10 Apr 2026 17:36:25 -0700 Subject: [PATCH 3/6] fix issues with sort --- ChangeLog.md | 5 +- faststack/app.py | 213 +++++++++++++++++++++++++----- faststack/io/deletion.py | 12 +- faststack/io/helicon.py | 3 - faststack/io/watcher.py | 10 +- faststack/tests/conftest.py | 47 +++++++ faststack/tests/test_sort_mode.py | 195 ++++++++++++++++++++------- 7 files changed, 388 insertions(+), 97 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 6dafab3..4f15455 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -365,11 +365,12 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better documentation ### Features - **JPG Fallback for Helicon:** Helicon Focus stacking now works with JPG-only workflows when RAW files absent. -- **Comprehensive Timing Instrumentation:** Added detailed decode timing logs in debug mode for performance analysis.- **Jump to Photo:** Press `G` to jump directly to any image (feature documented more fully in [1.0.0]). - **Comprehensive Timing Instrumentation:** Added detailed decode timing logs in debug mode for performance analysis. - **Jump to Photo:** Press `G` to jump directly to any image (feature documented more fully in [1.0.0]). -## [0.8.0] - 2025-11-20- Backspace key now deletes images (in addition to Delete key). Control-Z restores. +## [0.8.0] - 2025-11-20 + +- Backspace key now deletes images (in addition to Delete key). Control-Z restores. - Photoshop integration now automatically uses RAW files when available, falling back to JPG. - We now have some new color modes in the view menu to make the images in your monitor reflect reality. ICC profile mode works best on my system - try it if the images are over-saturated - or turn down the saturation in saturation mode. Test it out by loading an image in Faststack and Photoshop or another image viewer and make sure the colors look the same. diff --git a/faststack/app.py b/faststack/app.py index 466b705..657561a 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -39,7 +39,7 @@ QPoint, QCoreApplication, # noqa: F401 — patched by tests ) -from PySide6.QtWidgets import QApplication, QFileDialog +from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox from PySide6.QtQml import QQmlApplicationEngine from PIL import Image @@ -691,15 +691,73 @@ def set_sort_mode(self, mode: str): return if self.sort_mode == mode: return + + # --- Preflight: simulate the new order WITHOUT mutating state --- + have_stacks = bool(self.stacks) + clear_stacks = False + + if have_stacks: + new_order = self._simulate_sorted_list(mode) + new_path_to_idx = { + self._key(img.path): i for i, img in enumerate(new_order) + } + if not self._stacks_stay_contiguous(new_path_to_idx): + if not self._confirm_clear_stacks_for_sort(): + return # user cancelled — no state changed + clear_stacks = True + + # --- Past this point we are committed to the sort --- self.sort_mode = mode preserved_path = None if self.image_files and 0 <= self.current_index < len(self.image_files): preserved_path = self.image_files[self.current_index].path + # Snapshot paths referenced by batches (and stacks if preserving) + old_batch_paths = self._resolve_ranges_to_paths(self.batches) + old_stack_paths = ( + self._resolve_ranges_to_paths(self.stacks) if not clear_stacks else [] + ) + old_stack_start_path = ( + self.image_files[self.stack_start_index].path + if not clear_stacks + and self.stack_start_index is not None + and 0 <= self.stack_start_index < len(self.image_files) + else None + ) + old_batch_start_path = ( + self.image_files[self.batch_start_index].path + if self.batch_start_index is not None + and 0 <= self.batch_start_index < len(self.image_files) + else None + ) + self._apply_filter_to_cached_list() self._bump_display_generation() + # Remap batches (splitting is acceptable) + self.batches = self._rebuild_ranges_from_paths(old_batch_paths) + self._invalidate_batch_cache() + if old_batch_start_path: + key = self._key(old_batch_start_path) + self.batch_start_index = self._path_to_index.get(key) + + # Handle stacks + if clear_stacks: + self.stacks = [] + self.stack_start_index = None + self.sidecar.data.stacks = [] + self.sidecar.save() + elif have_stacks: + self.stacks = self._rebuild_ranges_from_paths(old_stack_paths) + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + + # Remap pending stack start marker (even when no completed stacks exist) + if not clear_stacks and old_stack_start_path: + key = self._key(old_stack_start_path) + self.stack_start_index = self._path_to_index.get(key) + if self.image_files and preserved_path: target_key = self._key(preserved_path) new_idx = self._path_to_index.get(target_key) @@ -729,6 +787,88 @@ def set_sort_mode(self, mode: str): if hasattr(self, "ui_state") and self.ui_state: self.ui_state.sortModeChanged.emit() + def _filtered_sorted_copy(self, mode: str) -> list: + """Return a filtered and sorted copy of ``_all_images``. + + Shared implementation for ``_simulate_sorted_list`` (read-only preview) + and ``_apply_filter_to_cached_list`` (mutating). + """ + if self._filter_enabled and self._filter_string: + needle = self._filter_string.lower() + result = [ + img for img in self._all_images if needle in img.path.stem.lower() + ] + else: + result = list(self._all_images) + + # Apply flag-based filtering (AND logic: image must have ALL checked flags) + if self._filter_enabled and self._filter_flags: + flags = self._filter_flags + result = [ + img + for img in result + if ( + (meta := self.sidecar.get_metadata(img.path, create=False)) + and all(getattr(meta, f, False) for f in flags) + ) + ] + + if mode == "filename": + result.sort(key=lambda img: img.path.name.lower()) + elif mode == "date": + # Use the timestamp captured at scan time (ImageFile.timestamp) + # so we avoid live filesystem calls during sort. Tiebreak on + # lowercase filename for determinism when mtimes are equal. + result.sort(key=lambda img: (-img.timestamp, img.path.name.lower())) + return result + + def _simulate_sorted_list(self, mode: str) -> list: + """Return the image list as it would appear under *mode*, without + mutating any controller state. + """ + return self._filtered_sorted_copy(mode) + + def _stacks_stay_contiguous(self, new_path_to_idx: dict) -> bool: + """Return True if every current stack would be contiguous under the + index mapping *new_path_to_idx* (keyed by ``_key(path)``). + """ + n = len(self.image_files) + for start, end in self.stacks: + indices = [] + for i in range(start, min(end, n - 1) + 1): + path = self.image_files[i].path + new_idx = new_path_to_idx.get(self._key(path)) + if new_idx is not None: + indices.append(new_idx) + if len(indices) <= 1: + continue + indices.sort() + for a, b in zip(indices, indices[1:]): + if b != a + 1: + return False + return True + + def _confirm_clear_stacks_for_sort(self) -> bool: + """Show a dialog asking whether to clear stacks to allow resorting. + Returns True if the user chose to clear and continue. + """ + msg_box = QMessageBox() + msg_box.setIcon(QMessageBox.Icon.Warning) + msg_box.setWindowTitle("Stacks Must Be Cleared") + msg_box.setText( + "The new sort order would break existing focus stacks.\n\n" + "Stacks are defined by contiguous image ranges. Resorting " + "would scatter stack members, so stacks must be cleared " + "before changing the sort order." + ) + clear_btn = msg_box.addButton( + "Clear Stacks and Resort", QMessageBox.ButtonRole.AcceptRole + ) + cancel_btn = msg_box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) + msg_box.setDefaultButton(cancel_btn) + msg_box.exec() + return msg_box.clickedButton() == clear_btn + def get_display_info(self): with self._display_lock: if self.is_zoomed: @@ -1111,38 +1251,7 @@ def _on_watcher_refresh(self): def _apply_filter_to_cached_list(self): """Applies current filter to cached image list without disk I/O.""" - if self._filter_enabled and self._filter_string: - needle = self._filter_string.lower() - filtered = [ - img for img in self._all_images if needle in img.path.stem.lower() - ] - else: - filtered = list(self._all_images) - - # Apply flag-based filtering (AND logic: image must have ALL checked flags) - if self._filter_enabled and self._filter_flags: - flags = self._filter_flags - result = [] - for img in filtered: - meta = self.sidecar.get_metadata(img.path, create=False) - if not meta: - continue - - # Check if all flags are present - # EntryMetadata is a simple object, getattr is fast - if all(getattr(meta, flag, False) for flag in flags): - result.append(img) - filtered = result - - if self.sort_mode == "filename": - filtered.sort(key=lambda img: img.path.name.lower()) - elif self.sort_mode == "date": - # Use the timestamp captured at scan time (ImageFile.timestamp) - # so we avoid live filesystem calls during sort. Tiebreak on - # lowercase filename for determinism when mtimes are equal. - filtered.sort(key=lambda img: (-img.timestamp, img.path.name.lower())) - - self.image_files = filtered + self.image_files = self._filtered_sorted_copy(self.sort_mode) self._rebuild_path_to_index() self.prefetcher.set_image_files(self.image_files) self._metadata_cache_index = (-1, -1) # Invalidate cache @@ -1157,6 +1266,44 @@ def _rebuild_path_to_index(self): self._key(img.path): i for i, img in enumerate(self.image_files) } + def _resolve_ranges_to_paths(self, ranges: List[List[int]]) -> List[List[Path]]: + """Convert index ranges to lists of paths for remap across reorder.""" + result = [] + n = len(self.image_files) + for start, end in ranges: + paths = [] + for i in range(start, min(end, n - 1) + 1): + paths.append(self.image_files[i].path) + if paths: + result.append(paths) + return result + + def _rebuild_ranges_from_paths(self, groups: List[List[Path]]) -> List[List[int]]: + """Rebuild index ranges from path groups after reorder.""" + ranges = [] + for paths in groups: + indices = [] + for p in paths: + idx = self._path_to_index.get(self._key(p)) + if idx is not None: + indices.append(idx) + if not indices: + continue + indices.sort() + # Merge into contiguous runs + run_start = indices[0] + run_end = indices[0] + for idx in indices[1:]: + if idx == run_end + 1: + run_end = idx + else: + ranges.append([run_start, run_end]) + run_start = idx + run_end = idx + ranges.append([run_start, run_end]) + ranges.sort() + return ranges + def _reindex_after_save(self, saved_path: str) -> bool: """Re-derive current_index to point at *saved_path* after a save. diff --git a/faststack/io/deletion.py b/faststack/io/deletion.py index 79a7ef8..4cd4375 100644 --- a/faststack/io/deletion.py +++ b/faststack/io/deletion.py @@ -66,7 +66,7 @@ def confirm_permanent_delete(image_file, reason: str = "") -> bool: file_list = "\n".join(f" • {f}" for f in files_to_delete) msg_box = QMessageBox() - msg_box.setIcon(QMessageBox.Warning) + msg_box.setIcon(QMessageBox.Icon.Warning) msg_box.setWindowTitle("Permanent Deletion") if reason: @@ -78,8 +78,8 @@ def confirm_permanent_delete(image_file, reason: str = "") -> bool: f"The following files will be permanently deleted:\n{file_list}" ) - delete_btn = msg_box.addButton("Delete Permanently", QMessageBox.DestructiveRole) - cancel_btn = msg_box.addButton("Cancel", QMessageBox.RejectRole) + delete_btn = msg_box.addButton("Delete Permanently", QMessageBox.ButtonRole.DestructiveRole) + cancel_btn = msg_box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) msg_box.setDefaultButton(cancel_btn) msg_box.exec() @@ -107,7 +107,7 @@ def confirm_batch_permanent_delete(images: list, reason: str = "") -> bool: total_files += 1 msg_box = QMessageBox() - msg_box.setIcon(QMessageBox.Warning) + msg_box.setIcon(QMessageBox.Icon.Warning) msg_box.setWindowTitle("Permanent Deletion") if reason: @@ -132,9 +132,9 @@ def confirm_batch_permanent_delete(images: list, reason: str = "") -> bool: ) delete_btn = msg_box.addButton( - f"Delete {len(images)} Images", QMessageBox.DestructiveRole + f"Delete {len(images)} Images", QMessageBox.ButtonRole.DestructiveRole ) - cancel_btn = msg_box.addButton("Cancel", QMessageBox.RejectRole) + cancel_btn = msg_box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) msg_box.setDefaultButton(cancel_btn) msg_box.exec() diff --git a/faststack/io/helicon.py b/faststack/io/helicon.py index 49ccf3a..86cbb25 100644 --- a/faststack/io/helicon.py +++ b/faststack/io/helicon.py @@ -90,6 +90,3 @@ def launch_helicon_focus(raw_files: List[Path]) -> Tuple[bool, Optional[Path]]: except (OSError, subprocess.SubprocessError) as e: log.exception(f"Failed to launch Helicon Focus: {e}") return False, None - except (IOError, PermissionError) as e: - log.exception(f"Failed to create temporary file for Helicon Focus: {e}") - return False, None diff --git a/faststack/io/watcher.py b/faststack/io/watcher.py index a9aa74d..acf2044 100644 --- a/faststack/io/watcher.py +++ b/faststack/io/watcher.py @@ -8,6 +8,7 @@ from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer +from watchdog.observers.api import BaseObserver log = logging.getLogger(__name__) @@ -72,7 +73,7 @@ class Watcher: """Manages the filesystem observer.""" def __init__(self, directory: Path, callback): - self.observer: Optional[Observer] = None # Initialize to None + self.observer: Optional[BaseObserver] = None # Initialize to None self.event_handler = ImageDirectoryEventHandler(callback) self.directory = directory self.callback = callback @@ -87,9 +88,10 @@ def start(self): return # Already running # Create a new observer instance every time, as it cannot be restarted - self.observer = Observer() - self.observer.schedule(self.event_handler, str(self.directory), recursive=False) - self.observer.start() + obs = Observer() + obs.schedule(self.event_handler, str(self.directory), recursive=False) + obs.start() + self.observer = obs log.info(f"Started watching directory: {self.directory}") def stop(self): diff --git a/faststack/tests/conftest.py b/faststack/tests/conftest.py index a5af71f..0ed9fe6 100644 --- a/faststack/tests/conftest.py +++ b/faststack/tests/conftest.py @@ -3,6 +3,9 @@ import os import signal import sys +from unittest.mock import MagicMock, patch + +import pytest def _dump_usr2(signum, frame): @@ -20,3 +23,47 @@ def pytest_configure(config): # Install a *non-terminating* handler if signal available (Unix only) if hasattr(signal, "SIGUSR2"): signal.signal(signal.SIGUSR2, _dump_usr2) + + +@pytest.fixture +def app_controller(tmp_path): + """Shared fixture: real AppController with all heavy dependencies mocked.""" + from PySide6.QtCore import QCoreApplication + + from faststack.app import AppController + + app = QCoreApplication.instance() + if not app: + app = QCoreApplication([]) + + image_dir = tmp_path / "images" + image_dir.mkdir() + + mock_engine = MagicMock() + + with ( + patch("faststack.app.Watcher"), + patch("faststack.app.SidecarManager"), + patch("faststack.app.Prefetcher"), + patch("faststack.app.ByteLRUCache"), + patch("faststack.app.config"), + patch("faststack.app.ThumbnailProvider"), + patch("faststack.app.ThumbnailModel"), + patch("faststack.app.ThumbnailPrefetcher"), + patch("faststack.app.ThumbnailCache"), + patch("faststack.app.Keybinder"), + patch("faststack.app.UIState"), + ): + controller = AppController(image_dir, mock_engine, debug_cache=False) + controller.refresh_image_list = MagicMock() + controller.update_status_message = MagicMock() + controller.sync_ui_state = MagicMock() + controller.image_cache = MagicMock() + controller.prefetcher = MagicMock() + controller._thumbnail_model = MagicMock() + controller._thumbnail_model.rowCount.return_value = 0 + controller._thumbnail_prefetcher = MagicMock() + controller._path_resolver = MagicMock() + controller.dataChanged = MagicMock() + controller.ui_state = MagicMock() + return controller diff --git a/faststack/tests/test_sort_mode.py b/faststack/tests/test_sort_mode.py index 2fc970d..fc200a5 100644 --- a/faststack/tests/test_sort_mode.py +++ b/faststack/tests/test_sort_mode.py @@ -1,56 +1,10 @@ """Tests for sort-mode feature (default / filename / date).""" -from pathlib import Path from unittest.mock import MagicMock, patch -import pytest - from faststack.models import ImageFile -@pytest.fixture -def app_controller(tmp_path): - from PySide6.QtCore import QCoreApplication - - from faststack.app import AppController - - app = QCoreApplication.instance() - if not app: - app = QCoreApplication([]) - - image_dir = tmp_path / "images" - image_dir.mkdir() - - mock_engine = MagicMock() - - with ( - patch("faststack.app.Watcher"), - patch("faststack.app.SidecarManager"), - patch("faststack.app.Prefetcher"), - patch("faststack.app.ByteLRUCache"), - patch("faststack.app.config"), - patch("faststack.app.ThumbnailProvider"), - patch("faststack.app.ThumbnailModel"), - patch("faststack.app.ThumbnailPrefetcher"), - patch("faststack.app.ThumbnailCache"), - patch("faststack.app.Keybinder"), - patch("faststack.app.UIState"), - ): - controller = AppController(image_dir, mock_engine, debug_cache=False) - controller.refresh_image_list = MagicMock() - controller.update_status_message = MagicMock() - controller.sync_ui_state = MagicMock() - controller.image_cache = MagicMock() - controller.prefetcher = MagicMock() - controller._thumbnail_model = MagicMock() - controller._thumbnail_model.rowCount.return_value = 0 - controller._thumbnail_prefetcher = MagicMock() - controller._path_resolver = MagicMock() - controller.dataChanged = MagicMock() - controller.ui_state = MagicMock() - return controller - - def _make_images(tmp_path, specs): """Create ImageFile objects from (name, timestamp) pairs. @@ -145,11 +99,14 @@ def test_date_sort_oserror_does_not_crash(app_controller, tmp_path): """ p_missing = tmp_path / "gone.jpg" p_missing.write_bytes(b"\xff\xd8") + p_ok = tmp_path / "ok.jpg" + p_ok.write_bytes(b"\xff\xd8") + # Remove the file so it is truly missing on disk + p_missing.unlink() imgs = [ - ImageFile(path=p_missing, timestamp=0.0), # simulates missing/failed stat - ImageFile(path=tmp_path / "ok.jpg", timestamp=5000), + ImageFile(path=p_missing, timestamp=0.0), # truly missing file + ImageFile(path=p_ok, timestamp=5000), ] - (tmp_path / "ok.jpg").write_bytes(b"\xff\xd8") _populate(app_controller, imgs) app_controller.set_sort_mode("date") @@ -241,3 +198,143 @@ def test_set_sort_mode_invalid_ignored(app_controller): """Invalid sort mode string must be silently ignored.""" app_controller.set_sort_mode("random") assert app_controller.sort_mode == "default" + + +# --- stack/batch preservation across sort --- + + +def test_sort_preserves_contiguous_stacks(app_controller, tmp_path): + """Stacks that stay contiguous after sort must be preserved.""" + # a,b,c in default order; filename sort gives same order (a,b,c) + imgs = _make_images( + tmp_path, + [("a.jpg", 3000), ("b.jpg", 2000), ("c.jpg", 1000)], + ) + _populate(app_controller, imgs) + # Stack covers indices 0-1 (a.jpg, b.jpg) — stays contiguous under filename sort + app_controller.stacks = [[0, 1]] + app_controller.sidecar = MagicMock() + app_controller.sidecar.data.stacks = [[0, 1]] + + app_controller.set_sort_mode("filename") + + assert app_controller.sort_mode == "filename" + # a.jpg and b.jpg are still adjacent at positions 0-1 + assert app_controller.stacks == [[0, 1]] + app_controller.sidecar.save.assert_called() + + +def test_sort_noncontiguous_stack_user_cancels(app_controller, tmp_path): + """Non-contiguous stack + user Cancel ⇒ nothing changes.""" + # default order: a, b, c. date sort: c(ts=3000), a(ts=2000), b(ts=1000) + # Stack [0,1] = a,b. Under date sort a→idx1, b→idx2: still contiguous. + # Stack [0,2] = a,b,c. Under date sort a→1,b→2,c→0: c=0,a=1,b=2 contiguous. + # We need non-contiguous: stack [0,1]={a,b} where date order scatters them. + # a(ts=1000), b(ts=3000), c(ts=2000). Date: b(3000), c(2000), a(1000). + # Stack[0,1]={a,b} → date indices: a→2, b→0. Not contiguous! + imgs = _make_images( + tmp_path, + [("a.jpg", 1000), ("b.jpg", 3000), ("c.jpg", 2000)], + ) + _populate(app_controller, imgs) + app_controller.stacks = [[0, 1]] + app_controller.sidecar = MagicMock() + app_controller.sidecar.data.stacks = [[0, 1]] + original_files = list(app_controller.image_files) + + with patch.object( + app_controller, "_confirm_clear_stacks_for_sort", return_value=False + ): + app_controller.set_sort_mode("date") + + # Everything unchanged + assert app_controller.sort_mode == "default" + assert app_controller.stacks == [[0, 1]] + assert app_controller.image_files == original_files + app_controller.sidecar.save.assert_not_called() + + +def test_sort_noncontiguous_stack_user_clears(app_controller, tmp_path): + """Non-contiguous stack + user confirms ⇒ stacks cleared, sort applied, sidecar saved.""" + imgs = _make_images( + tmp_path, + [("a.jpg", 1000), ("b.jpg", 3000), ("c.jpg", 2000)], + ) + _populate(app_controller, imgs) + app_controller.stacks = [[0, 1]] + app_controller.stack_start_index = 0 + app_controller.sidecar = MagicMock() + app_controller.sidecar.data.stacks = [[0, 1]] + app_controller.sidecar.get_metadata.return_value = None + + with patch.object( + app_controller, "_confirm_clear_stacks_for_sort", return_value=True + ): + app_controller.set_sort_mode("date") + + assert app_controller.sort_mode == "date" + assert app_controller.stacks == [] + assert app_controller.stack_start_index is None + # Sidecar must persist the cleared stacks + assert app_controller.sidecar.data.stacks == [] + app_controller.sidecar.save.assert_called() + # Date order: b(3000), c(2000), a(1000) + names = [img.path.name for img in app_controller.image_files] + assert names == ["b.jpg", "c.jpg", "a.jpg"] + + +def test_batch_split_does_not_block_sort(app_controller, tmp_path): + """Batches that become non-contiguous after sort must not block sorting.""" + imgs = _make_images( + tmp_path, + [("a.jpg", 1000), ("b.jpg", 3000), ("c.jpg", 2000)], + ) + _populate(app_controller, imgs) + # Batch covers a,b (indices 0-1). No stacks defined. + app_controller.batches = [[0, 1]] + app_controller.stacks = [] + app_controller.sidecar = MagicMock() + app_controller.sidecar.data.stacks = [] + app_controller.sidecar.get_metadata.return_value = None + + app_controller.set_sort_mode("date") + + assert app_controller.sort_mode == "date" + # Date order: b(3000), c(2000), a(1000) → a is at idx 2, b at idx 0 + # Batch should now contain both, possibly split into [0,0] and [2,2] + batch_indices = set() + for start, end in app_controller.batches: + for i in range(start, end + 1): + batch_indices.add(i) + # a.jpg → index 2, b.jpg → index 0 under date sort + a_idx = next( + i for i, img in enumerate(app_controller.image_files) if img.path.name == "a.jpg" + ) + b_idx = next( + i for i, img in enumerate(app_controller.image_files) if img.path.name == "b.jpg" + ) + assert a_idx in batch_indices + assert b_idx in batch_indices + + +def test_pending_stack_start_remapped_without_completed_stacks(app_controller, tmp_path): + """A pending stack_start_index must follow its image through a sort, + even when no completed stacks exist.""" + # Default order: a(idx0), b(idx1), c(idx2) + # Date order: b(3000)→idx0, c(2000)→idx1, a(1000)→idx2 + imgs = _make_images( + tmp_path, + [("a.jpg", 1000), ("b.jpg", 3000), ("c.jpg", 2000)], + ) + _populate(app_controller, imgs) + app_controller.stacks = [] # no completed stacks + app_controller.stack_start_index = 0 # pending start on a.jpg + app_controller.sidecar = MagicMock() + app_controller.sidecar.data.stacks = [] + app_controller.sidecar.get_metadata.return_value = None + + app_controller.set_sort_mode("date") + + assert app_controller.sort_mode == "date" + # a.jpg moved to index 2 under date sort + assert app_controller.image_files[app_controller.stack_start_index].path.name == "a.jpg" From c52282f870399b067e32f771ed43cbb647133007 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Fri, 10 Apr 2026 17:36:37 -0700 Subject: [PATCH 4/6] format with black --- faststack/io/deletion.py | 4 +++- faststack/tests/test_sort_mode.py | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/faststack/io/deletion.py b/faststack/io/deletion.py index 4cd4375..812e722 100644 --- a/faststack/io/deletion.py +++ b/faststack/io/deletion.py @@ -78,7 +78,9 @@ def confirm_permanent_delete(image_file, reason: str = "") -> bool: f"The following files will be permanently deleted:\n{file_list}" ) - delete_btn = msg_box.addButton("Delete Permanently", QMessageBox.ButtonRole.DestructiveRole) + delete_btn = msg_box.addButton( + "Delete Permanently", QMessageBox.ButtonRole.DestructiveRole + ) cancel_btn = msg_box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) msg_box.setDefaultButton(cancel_btn) diff --git a/faststack/tests/test_sort_mode.py b/faststack/tests/test_sort_mode.py index fc200a5..45f67ce 100644 --- a/faststack/tests/test_sort_mode.py +++ b/faststack/tests/test_sort_mode.py @@ -308,16 +308,22 @@ def test_batch_split_does_not_block_sort(app_controller, tmp_path): batch_indices.add(i) # a.jpg → index 2, b.jpg → index 0 under date sort a_idx = next( - i for i, img in enumerate(app_controller.image_files) if img.path.name == "a.jpg" + i + for i, img in enumerate(app_controller.image_files) + if img.path.name == "a.jpg" ) b_idx = next( - i for i, img in enumerate(app_controller.image_files) if img.path.name == "b.jpg" + i + for i, img in enumerate(app_controller.image_files) + if img.path.name == "b.jpg" ) assert a_idx in batch_indices assert b_idx in batch_indices -def test_pending_stack_start_remapped_without_completed_stacks(app_controller, tmp_path): +def test_pending_stack_start_remapped_without_completed_stacks( + app_controller, tmp_path +): """A pending stack_start_index must follow its image through a sort, even when no completed stacks exist.""" # Default order: a(idx0), b(idx1), c(idx2) @@ -337,4 +343,7 @@ def test_pending_stack_start_remapped_without_completed_stacks(app_controller, t assert app_controller.sort_mode == "date" # a.jpg moved to index 2 under date sort - assert app_controller.image_files[app_controller.stack_start_index].path.name == "a.jpg" + assert ( + app_controller.image_files[app_controller.stack_start_index].path.name + == "a.jpg" + ) From db43707aac0149d9b6f955cbaf483a4f63caa125 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Fri, 10 Apr 2026 17:51:37 -0700 Subject: [PATCH 5/6] small fixes --- faststack/app.py | 7 +++++-- faststack/tests/conftest.py | 2 +- faststack/tests/test_sort_mode.py | 9 +++++---- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index 657561a..bfc09fd 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -750,8 +750,11 @@ def set_sort_mode(self, mode: str): self.sidecar.save() elif have_stacks: self.stacks = self._rebuild_ranges_from_paths(old_stack_paths) - self.sidecar.data.stacks = self.stacks - self.sidecar.save() + # Only persist to sidecar when no filter is active — filtered + # image_files may hide stack members, producing incomplete ranges. + if not self._filter_enabled: + self.sidecar.data.stacks = self.stacks + self.sidecar.save() # Remap pending stack start marker (even when no completed stacks exist) if not clear_stacks and old_stack_start_path: diff --git a/faststack/tests/conftest.py b/faststack/tests/conftest.py index 0ed9fe6..e6e51fc 100644 --- a/faststack/tests/conftest.py +++ b/faststack/tests/conftest.py @@ -66,4 +66,4 @@ def app_controller(tmp_path): controller._path_resolver = MagicMock() controller.dataChanged = MagicMock() controller.ui_state = MagicMock() - return controller + yield controller diff --git a/faststack/tests/test_sort_mode.py b/faststack/tests/test_sort_mode.py index 45f67ce..c0c9ce1 100644 --- a/faststack/tests/test_sort_mode.py +++ b/faststack/tests/test_sort_mode.py @@ -185,10 +185,11 @@ def test_set_sort_mode_noop_when_unchanged(app_controller, tmp_path): imgs = _make_images(tmp_path, [("a.jpg", 1000)]) _populate(app_controller, imgs) + prev = app_controller.sync_ui_state.call_count app_controller.set_sort_mode("default") # already default - # sync_ui_state should not be called again (the fixture's mock tracks calls) - app_controller.sync_ui_state.assert_not_called() + # sync_ui_state should not be called again + assert app_controller.sync_ui_state.call_count == prev # --- invalid sort mode ignored --- @@ -317,8 +318,7 @@ def test_batch_split_does_not_block_sort(app_controller, tmp_path): for i, img in enumerate(app_controller.image_files) if img.path.name == "b.jpg" ) - assert a_idx in batch_indices - assert b_idx in batch_indices + assert batch_indices == {a_idx, b_idx} def test_pending_stack_start_remapped_without_completed_stacks( @@ -343,6 +343,7 @@ def test_pending_stack_start_remapped_without_completed_stacks( assert app_controller.sort_mode == "date" # a.jpg moved to index 2 under date sort + assert app_controller.stack_start_index == 2 assert ( app_controller.image_files[app_controller.stack_start_index].path.name == "a.jpg" From 0ad52602f1622ff9cbdb24d929d035834e432b69 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Fri, 10 Apr 2026 18:04:59 -0700 Subject: [PATCH 6/6] small fixes --- faststack/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/faststack/app.py b/faststack/app.py index bfc09fd..3daf36a 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -16,6 +16,7 @@ import uuid import functools from collections import deque +from itertools import pairwise # Must set before importing PySide6 os.environ["QT_LOGGING_RULES"] = "qt.qpa.mime.warning=false" @@ -846,7 +847,7 @@ def _stacks_stay_contiguous(self, new_path_to_idx: dict) -> bool: if len(indices) <= 1: continue indices.sort() - for a, b in zip(indices, indices[1:]): + for a, b in pairwise(indices): if b != a + 1: return False return True