diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 3c31ae0..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(python:*)" - ] - } -} diff --git a/.gitattributes b/.gitattributes index 399a98d..3d2968c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -37,7 +37,6 @@ *.so binary *.dll binary *.dylib binary -*.tiff binary *.nef binary *.cr2 binary *.cr3 binary diff --git a/.gitignore b/.gitignore index 80d3b39..6494101 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ WARP.md AGENTS.md ARCHITECTURE.md docs/COLOR_PROFILE_FIX.md +.claude/ # ---------------------------- # Runtime / generated data diff --git a/ChangeLog.md b/ChangeLog.md index ed007d3..5532fc1 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -2,6 +2,32 @@ Todo: Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. +## 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. + - Registered a dedicated QML image provider (`thumbnail://...`) and exposed `thumbnailModel` to QML. +- UI controls to switch between **Thumbnail View** and **Single Image View**: + - Menu item in the actions menu to toggle views. + - `T` shortcut to toggle grid/loupe view. + - 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: + - Clear thumbnail cache before refresh to avoid stale thumbnails. + - Update model directories, refresh, update resolver, and emit `gridDirectoryChanged`. +- 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) diff --git a/faststack/app.py b/faststack/app.py index 85c83d6..027432e 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -8,7 +8,7 @@ import time import argparse from pathlib import Path -from typing import Optional, List, Dict, Any, Tuple +from typing import Optional, List, Dict, Any, Tuple, Set from datetime import date import os import shutil @@ -58,6 +58,13 @@ from faststack.ui.keystrokes import Keybinder from faststack.imaging.editor import ImageEditor, ASPECT_RATIOS, create_backup_file from faststack.imaging.metadata import get_exif_data +from faststack.thumbnail_view import ( + ThumbnailModel, + ThumbnailPrefetcher, + ThumbnailCache, + ThumbnailProvider, + PathResolver, +) import re import numpy as np from faststack.io.indexer import RAW_EXTENSIONS @@ -95,6 +102,8 @@ class AppController(QObject): histogramReady = Signal(object) # Signal for off-thread histogram result previewReady = Signal(object) # Signal for off-thread preview result dialogStateChanged = Signal(bool) # Signal for dialog open/close state + # Thread-safe signal for thumbnail ready (emitted from worker thread, received on GUI thread) + _thumbnailReadySignal = Signal(str) class ProgressReporter(QObject): progress_updated = Signal(int) @@ -126,6 +135,7 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: self.image_dir = image_dir self.image_files: List[ImageFile] = [] # Filtered list for display self._all_images: List[ImageFile] = [] # Cached full list from disk + self._path_to_index: Dict[Path, int] = {} # Resolved path -> index for O(1) lookup self.current_index: int = 0 self.ui_refresh_generation = 0 self.main_window: Optional[QObject] = None @@ -177,6 +187,38 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: self.last_displayed_image: Optional[DecodedImage] = None # Cache last image to avoid grey squares self._last_image_lock = threading.Lock() # Protect last_displayed_image from race conditions + # -- Grid View (Thumbnail) Infrastructure -- + self._is_grid_view_active = True # Default to grid view on startup + self._thumbnail_cache = ThumbnailCache( + max_bytes=256 * 1024 * 1024, # 256 MB + max_items=5000 + ) + self._path_resolver = PathResolver() + self._thumbnail_prefetcher = ThumbnailPrefetcher( + cache=self._thumbnail_cache, + on_ready_callback=self._on_thumbnail_ready, + target_size=200, + ) + self._thumbnail_model = ThumbnailModel( + base_directory=self.image_dir, + current_directory=self.image_dir, + get_metadata_callback=self._get_metadata_dict, + get_batch_indices_callback=self._get_batch_indices, + get_current_index_callback=self._get_current_loupe_index, + thumbnail_size=200, + parent=self, # Ensure proper Qt ownership to prevent GC issues + ) + self._thumbnail_provider = ThumbnailProvider( + cache=self._thumbnail_cache, + prefetcher=self._thumbnail_prefetcher, + path_resolver=self._path_resolver.resolve, + default_size=200, + ) + # Connect thread-safe thumbnail ready signal to GUI thread handler + # The callback is invoked from worker threads, so we use a signal to hop to GUI thread + # Explicit QueuedConnection ensures cross-thread safety + self._thumbnailReadySignal.connect(self._on_thumbnail_ready_gui, Qt.QueuedConnection) + # -- UI State -- self.ui_state = UIState(self) self.ui_state.theme = self.get_theme() @@ -187,6 +229,10 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: self.ui_state.isDecoding = False # Initialize decoding indicator self.is_zoomed = False # Track zoom state for high-res loading logic + # Connect model selection changes to UIState for QML property notification + # Must connect to .emit (not the signal itself) for signal-to-signal forwarding + self._thumbnail_model.selectionChanged.connect(self.ui_state.gridSelectedCountChanged.emit) + # -- Stacking State -- self.stack_start_index: Optional[int] = None self.stacks: List[List[int]] = [] @@ -485,8 +531,12 @@ def _do_prefetch(self, index: int, is_navigation: bool = False, direction: Optio return self.prefetcher.update_prefetch(index, is_navigation=is_navigation, direction=direction) - def load(self): - """Loads images, sidecar data, and starts services.""" + def load(self, skip_thumbnail_refresh: bool = False): + """Loads images, sidecar data, and starts services. + + Args: + skip_thumbnail_refresh: If True, skip thumbnail model refresh (caller already did it). + """ self.refresh_image_list() # Initial scan from disk if not self.image_files: self.current_index = 0 @@ -496,10 +546,14 @@ def load(self): self.dataChanged.emit() # Emit after stacks are loaded self.watcher.start() self._do_prefetch(self.current_index) - + # Defer initial UI sync until after images are loaded self.sync_ui_state() + # Initialize grid view if starting in grid mode (unless already done) + if self._is_grid_view_active and not skip_thumbnail_refresh: + self._thumbnail_model.refresh() + self._path_resolver.update_from_model(self._thumbnail_model) def refresh_image_list(self): """Rescans the directory for images from disk and updates cache. @@ -525,10 +579,20 @@ def _apply_filter_to_cached_list(self): else: self.image_files = self._all_images + self._rebuild_path_to_index() self.prefetcher.set_image_files(self.image_files) self._metadata_cache_index = (-1, -1) # Invalidate cache self.ui_state.imageCountChanged.emit() + def _rebuild_path_to_index(self): + """Rebuild path-to-index dict for O(1) lookup in grid_open_index. + + Call this whenever self.image_files is mutated (filter, sort, directory change). + """ + self._path_to_index = { + img.path.resolve(): i for i, img in enumerate(self.image_files) + } + def get_decoded_image(self, index: int) -> Optional[DecodedImage]: """Retrieves a decoded image, blocking until ready to ensure correct display. @@ -925,7 +989,140 @@ def dialog_closed(self): log.debug("Dialog closed (count=0), re-enabling global keybindings") def toggle_grid_view(self): - log.warning("Grid view not implemented yet.") + """Toggle between grid view and loupe (single image) view.""" + self._set_grid_view_active(not self._is_grid_view_active) + + def _set_grid_view_active(self, active: bool): + """Set grid view active state and handle side effects.""" + if self._is_grid_view_active == active: + return + + self._is_grid_view_active = active + + if active: + # Entering grid view - refresh the model + self._thumbnail_model.refresh() + # Update path resolver for the current directory + self._path_resolver.update_from_model(self._thumbnail_model) + log.info("Switched to grid view") + else: + log.info("Switched to loupe view") + + # Notify UI state via signal + self.ui_state.isGridViewActiveChanged.emit(active) + + def grid_navigate_to(self, path: str): + """Navigate to a folder in grid view. + + This updates both the grid view AND the main working directory, + so loupe view will show images from the new folder. + """ + if not self._is_grid_view_active: + return + + folder_path = Path(path) + if not folder_path.is_dir(): + log.warning("Cannot navigate to non-directory: %s", path) + return + + # Use canonical directory switch (keeps base directory for grid navigation) + self._switch_to_directory(folder_path, update_base_directory=False) + log.info("Grid view navigated to: %s", folder_path) + + def grid_open_index(self, index: int): + """Open an image from grid view in loupe view.""" + entry = self._thumbnail_model.get_entry(index) + if not entry: + log.warning("grid_open_index: no entry at index %d", index) + return + + if entry.is_folder: + # Navigate into folder instead of opening + self.grid_navigate_to(str(entry.path)) + return + + # Find this image in the main image list using O(1) lookup + resolved_path = entry.path.resolve() + loupe_index = self._path_to_index.get(resolved_path) + + if loupe_index is None: + # Index might be stale - rebuild and retry once + self._rebuild_path_to_index() + loupe_index = self._path_to_index.get(resolved_path) + + if loupe_index is None: + log.warning("grid_open_index: image not found in current list: %s", entry.path) + # Image might be in a different directory - don't switch view + return + + self.current_index = loupe_index + + # Switch to loupe view + self._set_grid_view_active(False) + + # Sync UI and trigger image load + self.sync_ui_state() + self.prefetcher.update_prefetch(self.current_index) + log.info("Opened image from grid: %s", entry.path) + + def _on_thumbnail_ready(self, thumbnail_id: str): + """Callback when a thumbnail finishes decoding (called from worker thread). + + This emits a signal to hop to the GUI thread for thread-safe model updates. + """ + self._thumbnailReadySignal.emit(thumbnail_id) + + @Slot(str) + def _on_thumbnail_ready_gui(self, thumbnail_id: str): + """Handle thumbnail ready on GUI thread (thread-safe).""" + # Guard against callbacks during/after shutdown + if getattr(self, "_shutting_down", False): + return + if self._thumbnail_model: + self._thumbnail_model.thumbnailReady.emit(thumbnail_id) + + def _get_metadata_dict(self, stem: str) -> dict: + """Get metadata for a file stem as a dict for thumbnail model.""" + try: + meta = self.sidecar.get_metadata(stem) + return { + "stacked": getattr(meta, "stacked", False), + "uploaded": getattr(meta, "uploaded", False), + "edited": getattr(meta, "edited", False), + "restacked": getattr(meta, "restacked", False), + } + except Exception: + return {"stacked": False, "uploaded": False, "edited": False, "restacked": False} + + def _invalidate_batch_cache(self): + """Clear the batch indices cache. Call after mutating self.batches.""" + if hasattr(self, "_batch_indices_cache"): + self._batch_indices_cache = set() + self._batch_indices_cache_key = None + + def _get_batch_indices(self) -> Set[int]: + """Get set of all indices that are in any batch (for thumbnail model). + + Cached to avoid O(batch_span) computation on every delegate paint. + """ + # Check if cache is valid (batches haven't changed) + cache_key = tuple(tuple(b) for b in self.batches) + if hasattr(self, '_batch_indices_cache_key') and self._batch_indices_cache_key == cache_key: + return self._batch_indices_cache + + # Rebuild cache + indices: Set[int] = set() + for start, end in self.batches: + for i in range(start, end + 1): + indices.add(i) + + self._batch_indices_cache = indices + self._batch_indices_cache_key = cache_key + return indices + + def _get_current_loupe_index(self) -> int: + """Get current loupe view index (for thumbnail model).""" + return self.current_index def toggle_uploaded(self): """Toggle uploaded flag for current image.""" @@ -1100,6 +1297,7 @@ def end_current_batch(self): end = max(self.batch_start_index, self.current_index) self.batches.append([start, end]) self.batches.sort() # Keep batches sorted by start index + self._invalidate_batch_cache() log.info("Defined new batch: [%d, %d]", start, end) self.batch_start_index = None self._metadata_cache_index = (-1, -1) # Invalidate cache @@ -1149,7 +1347,8 @@ def remove_from_batch_or_stack(self): if batch_modified: self.batches = new_batches - + self._invalidate_batch_cache() + # Check and remove from stacks # Check and remove from stacks if not removed: @@ -1261,7 +1460,8 @@ def toggle_batch_membership(self): self.update_status_message("Added image to batch") log.info("Added index %d to batch.", index_to_toggle) - + + self._invalidate_batch_cache() self._metadata_cache_index = (-1, -1) self.dataChanged.emit() self.sync_ui_state() @@ -1481,7 +1681,8 @@ def clear_all_batches(self): log.info("Clearing all defined batches.") self.batches = [] self.batch_start_index = None - + self._invalidate_batch_cache() + self._metadata_cache_index = (-1, -1) self.dataChanged.emit() self.sync_ui_state() @@ -1879,47 +2080,75 @@ def open_directory_dialog(self): return dialog.selectedFiles()[0] return "" + def _switch_to_directory(self, folder_path: Path, update_base_directory: bool = True): + """Canonical directory switch - used by both open_folder() and grid_navigate_to(). + + Args: + folder_path: The directory to switch to. + update_base_directory: If True, also updates the thumbnail model's base directory + (for File -> Open Folder). If False, keeps existing base + (for grid navigation within current base). + """ + # Stop the old watcher + if self.watcher: + self.watcher.stop() + + # Update the directory path + self.image_dir = folder_path + + # Reinitialize directory-bound components + self.watcher = Watcher(self.image_dir, self.refresh_image_list) + self.sidecar = SidecarManager(self.image_dir, self.watcher, debug=_debug_mode) + self.recycle_bin_dir = self.image_dir / "image recycle bin" + + # Clear directory-specific state + self.delete_history = [] + self.undo_history = [] + self.stacks = [] + self.batches = [] + self.batch_start_index = None + self.stack_start_index = None + + # Clear caches since they reference old directory's images + with self._last_image_lock: + self.last_displayed_image = None + self.image_cache.clear() + self.prefetcher.cancel_all() + self.display_generation += 1 + self._metadata_cache = {} + self._metadata_cache_index = (-1, -1) + + # Clear batch indices cache (avoids stale batch membership checks) + if hasattr(self, "_batch_indices_cache"): + self._batch_indices_cache = set() + self._batch_indices_cache_key = None + + # Clear editor state if open + self.image_editor.clear() + + # Clear thumbnail cache BEFORE refresh to avoid stale thumbs + if self._thumbnail_cache: + self._thumbnail_cache.clear() + + # Update thumbnail view infrastructure + if self._thumbnail_model: + if update_base_directory: + self._thumbnail_model.set_directories(self.image_dir, self.image_dir) + else: + self._thumbnail_model.navigate_to(self.image_dir) + self._thumbnail_model.refresh() + self._path_resolver.update_from_model(self._thumbnail_model) + self.ui_state.gridDirectoryChanged.emit(str(self.image_dir)) + + # Load images from new directory (thumbnail model already refreshed above) + self.load(skip_thumbnail_refresh=True) + @Slot() def open_folder(self): """Opens a directory dialog and reloads the application with the selected folder.""" path = self.open_directory_dialog() if path: - # Stop the old watcher - if self.watcher: - self.watcher.stop() - - # Update the directory path - self.image_dir = Path(path) - - # Reinitialize directory-bound components - self.watcher = Watcher(self.image_dir, self.refresh_image_list) - self.sidecar = SidecarManager(self.image_dir, self.watcher, debug=_debug_mode) - self.recycle_bin_dir = self.image_dir / "image recycle bin" - - # Clear directory-specific state - self.delete_history = [] - self.undo_history = [] - self.stacks = [] - self.batches = [] - self.batch_start_index = None - self.stack_start_index = None - - # Clear caches since they reference old directory's images - with self._last_image_lock: - self.last_displayed_image = None - self.image_cache.clear() - self.prefetcher.cancel_all() - self.display_generation += 1 - self._metadata_cache = {} - self._metadata_cache_index = (-1, -1) - # Clear last displayed image since it references the old directory - with self._last_image_lock: - self.last_displayed_image = None - # Clear editor state if open - self.image_editor.clear() - - # Load images from new directory - self.load() + self._switch_to_directory(Path(path), update_base_directory=True) def preload_all_images(self): @@ -2234,7 +2463,8 @@ def delete_batch_images(self): # Clear all batches after deletion self.batches = [] self.batch_start_index = None - + self._invalidate_batch_cache() + # Refresh image list self.refresh_image_list() @@ -2519,6 +2749,9 @@ def shutdown(self): self.watcher.stop() self.prefetcher.shutdown() + # Guard against partial init + if getattr(self, "_thumbnail_prefetcher", None): + self._thumbnail_prefetcher.shutdown() self.sidecar.set_last_index(self.current_index) self.sidecar.save() @@ -2832,7 +3065,8 @@ def start_drag_current_image(self): # Clear all batches after successful drag (like pressing \) self.batches = [] self.batch_start_index = None - + self._invalidate_batch_cache() + self._metadata_cache_index = (-1, -1) self.dataChanged.emit() self.sync_ui_state() @@ -4256,11 +4490,14 @@ def main(image_dir: str = "", debug: bool = False, debug_cache: bool = False): log.info("Startup: after AppController: %.3fs", time.perf_counter() - t0) image_provider = ImageProvider(controller) engine.addImageProvider("provider", image_provider) + # Register thumbnail provider for grid view + engine.addImageProvider("thumbnail", controller._thumbnail_provider) # Expose controller and UI state to QML context = engine.rootContext() context.setContextProperty("uiState", controller.ui_state) context.setContextProperty("controller", controller) + context.setContextProperty("thumbnailModel", controller._thumbnail_model) qml_file = Path(__file__).parent / "qml" / "Main.qml" engine.load(QUrl.fromLocalFile(str(qml_file))) diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 30accdf..97c06c4 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -597,6 +597,33 @@ ApplicationWindow { leftPadding: 10 } } + + // Separator before grid view toggle + Rectangle { + width: 220 + height: 1 + color: root.isDarkTheme ? "#666666" : "#cccccc" + } + + // Toggle Grid/Loupe View + ItemDelegate { + width: 220 + height: 36 + text: uiState && uiState.isGridViewActive ? "Single Image View" : "Thumbnail View" + onClicked: { + if (uiState) uiState.toggleGridView(); + actionsMenu.close() + } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } } } @@ -642,7 +669,7 @@ ApplicationWindow { enabled: uiState ? !uiState.isDialogOpen : true onActivated: { if (!uiState) return - + if (uiState.isEditorOpen) { uiState.isEditorOpen = false } else { @@ -654,34 +681,70 @@ ApplicationWindow { } } + // Grid View Toggle (T for Thumbnails) + Shortcut { + sequence: "T" + context: Qt.ApplicationShortcut + enabled: uiState ? !uiState.isDialogOpen : true + onActivated: { + if (uiState) uiState.toggleGridView() + } + } + // -------- MAIN VIEW -------- - Item { + // StackLayout to switch between loupe and grid view + StackLayout { id: contentArea anchors.fill: parent + currentIndex: uiState && uiState.isGridViewActive ? 1 : 0 + + // Index 0: Loupe View (single image) + Item { + id: loupeViewContainer + Layout.fillWidth: true + Layout.fillHeight: true + + Loader { + id: mainViewLoader + anchors.fill: parent + source: "Components.qml" + focus: !uiState || !uiState.isGridViewActive + onLoaded: item.footerHeight = Qt.binding(function() { return root.footerHeight }) + + // Key bindings implemented in old Main.qml + Keys.onPressed: function(event) { + if (!uiState || !controller) { + return + } - Loader { - id: mainViewLoader - anchors.fill: parent - source: "Components.qml" - focus: true - onLoaded: item.footerHeight = Qt.binding(function() { return root.footerHeight }) - - // Key bindings implemented in old Main.qml - Keys.onPressed: function(event) { - if (!uiState || !controller) { - return - } - - // Global Key for saving edited image (Ctrl+S) when editor is open - if (event.key === Qt.Key_S && (event.modifiers & Qt.ControlModifier)) { - if (uiState.isEditorOpen) { - controller.save_edited_image() - event.accepted = true + // Global Key for saving edited image (Ctrl+S) when editor is open + if (event.key === Qt.Key_S && (event.modifiers & Qt.ControlModifier)) { + if (uiState.isEditorOpen) { + controller.save_edited_image() + event.accepted = true + } } } } } + // Index 1: Grid View (thumbnail browser) + Item { + id: gridViewContainer + Layout.fillWidth: true + Layout.fillHeight: true + + Loader { + id: gridViewLoader + anchors.fill: parent + source: "ThumbnailGridView.qml" + active: true // Keep loaded to preserve state during view toggle + visible: uiState && uiState.isGridViewActive + focus: uiState && uiState.isGridViewActive + } + } + } + // -------- STATUS BAR OVERLAY -------- Rectangle { z: 100 @@ -838,9 +901,49 @@ ApplicationWindow { visible: uiState ? (uiState.statusMessage !== "") : false Layout.rightMargin: 10 } + + // Grid view controls (visible when in grid view) + Row { + visible: uiState && uiState.isGridViewActive + spacing: 8 + Layout.rightMargin: 10 + + // Selection info (uses efficient count property, not full list) + Label { + property int selCount: uiState ? uiState.gridSelectedCount : 0 + text: selCount > 0 ? selCount + " selected" : "" + color: "#4CAF50" + visible: selCount > 0 + anchors.verticalCenter: parent.verticalCenter + } + + // Clear selection button + Button { + text: "Clear" + visible: uiState ? uiState.gridSelectedCount > 0 : false + onClicked: uiState.gridClearSelection() + implicitWidth: 60 + implicitHeight: 28 + } + + // Refresh button + Button { + text: "Refresh" + onClicked: uiState.gridRefresh() + implicitWidth: 70 + implicitHeight: 28 + } + + // Single Image View button + Button { + text: "Single Image" + onClicked: uiState.toggleGridView() + implicitWidth: 90 + implicitHeight: 28 + } + } } } - } // -------- DIALOGS -------- @@ -873,15 +976,19 @@ ApplicationWindow { "  J / Right Arrow: Next Image
" + "  K / Left Arrow: Previous Image
" + "  G: Jump to Image Number
" + - "  I: Show EXIF Data

" + + "  I: Show EXIF Data
" + + "  T: Toggle Thumbnail Grid / Single Image View

" + + "Thumbnail Grid View:
" + + "  Click: Open image in single view
" + + "  Ctrl+Click: Toggle selection
" + + "  Shift+Click: Select range
" + + "  Backspace: Navigate to parent folder
" + + "  Esc: Clear selection or switch to single view

" + "Viewing:
" + "  Mouse Wheel: Zoom in/out
" + "  Left-click + Drag: Pan image
" + "  Ctrl+0: Reset zoom and pan to fit window
" + - "  Ctrl+1: Zoom to 100%
" + - "  Ctrl+2: Zoom to 200%
" + - "  Ctrl+3: Zoom to 300%
" + - "  Ctrl+4: Zoom to 400%

" + + "  Ctrl+1/2/3/4: Zoom to 100%/200%/300%/400%

" + "Stacking:
" + "  [: Begin new stack
" + "  ]: End current stack
" + @@ -907,20 +1014,23 @@ ApplicationWindow { "  Ctrl+E: Toggle edited flag
" + "  Ctrl+S: Toggle stacked flag

" + "File Management:
" + - "  Delete: Move current image to recycle bin
" + - "  Ctrl+Z: Undo last action (delete, auto white balance, or crop)

" + - "Actions:
" + + "  Delete/Backspace: Move current image to recycle bin
" + + "  Ctrl+Z: Undo last action

" + + "Image Editing:
" + + "  E: Toggle Image Editor
" + + "  Ctrl+S (in editor): Save edited image
" + + "  A: Quick auto white balance
" + + "  L: Quick auto levels
" + + "  O (or right-click): Toggle crop mode
" + + "    1/2/3/4: Set aspect ratio (1:1, 4:3, 3:2, 16:9)
" + + "    Enter: Execute crop
" + + "    Esc: Cancel crop

" + + "Other Actions:
" + "  Enter: Launch Helicon Focus
" + "  P: Edit in Photoshop
" + - "  Backspace/Del: Move current image to recycle bin
" + - "  A: Quick auto white balance (saves automatically)
" + - "  L: Quick auto levels (saves automatically)
" + - "  Ctrl+Shift+B: Quick auto white balance (saves automatically)
" + - "  O (or right mouse click): Toggle crop mode (Enter to execute, ESC to cancel)
" + "  H: Toggle histogram window
" + - "  E: Toggle Image Editor (closes without saving if open)
" + "  Ctrl+C: Copy image path to clipboard
" + - "  Esc: Close active dialog, editor, or cancel crop" + "  Esc: Close active dialog or editor" padding: 10 wrapMode: Text.WordWrap color: root.currentTextColor diff --git a/faststack/qml/ThumbnailGridView.qml b/faststack/qml/ThumbnailGridView.qml new file mode 100644 index 0000000..ec4ff2d --- /dev/null +++ b/faststack/qml/ThumbnailGridView.qml @@ -0,0 +1,185 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +// Main grid view for thumbnail browser +Item { + id: gridViewRoot + anchors.fill: parent + + // Configuration + property int cellWidth: 190 + property int cellHeight: 210 + + // Selection info (for keyboard handler and external access) + property var selectedPaths: uiState ? uiState.gridGetSelectedPaths() : [] + + Connections { + target: thumbnailModel + function onDataChanged() { + gridViewRoot.selectedPaths = uiState ? uiState.gridGetSelectedPaths() : [] + } + } + + // Grid view + GridView { + id: thumbnailGrid + anchors.fill: parent + anchors.margins: 8 + + cellWidth: gridViewRoot.cellWidth + cellHeight: gridViewRoot.cellHeight + clip: true + + model: thumbnailModel + + delegate: ThumbnailTile { + width: thumbnailGrid.cellWidth - 10 + height: thumbnailGrid.cellHeight - 10 + + // Model role bindings - use attached property 'index' directly + // Model roles become context properties in delegate + tileIndex: index + tileFilePath: filePath || "" + tileFileName: fileName || "" + tileIsFolder: isFolder || false + tileIsStacked: isStacked || false + tileIsUploaded: isUploaded || false + tileIsEdited: isEdited || false + tileIsRestacked: isRestacked || false + tileIsInBatch: isInBatch || false + tileIsCurrent: isCurrent || false + tileThumbnailSource: thumbnailSource || "" + tileFolderStats: folderStats || null + tileIsSelected: isSelected || false + tileIsParentFolder: isParentFolder || false + } + + // Scroll bar + ScrollBar.vertical: ScrollBar { + active: true + policy: ScrollBar.AsNeeded + } + + // Visible range prefetch + property int prefetchMargin: 2 // rows + + onContentYChanged: { + prefetchTimer.restart() + } + + Timer { + id: prefetchTimer + interval: 100 + repeat: false + onTriggered: { + thumbnailGrid.triggerPrefetch() + } + } + + function triggerPrefetch() { + if (thumbnailGrid.count === 0) return + + // Calculate visible range + var topIndex = thumbnailGrid.indexAt(thumbnailGrid.contentX, thumbnailGrid.contentY) + var bottomIndex = thumbnailGrid.indexAt( + thumbnailGrid.contentX + thumbnailGrid.width, + thumbnailGrid.contentY + thumbnailGrid.height + ) + + if (topIndex < 0) topIndex = 0 + if (bottomIndex < 0) bottomIndex = thumbnailGrid.count - 1 + + // Add margin + var cols = Math.floor(thumbnailGrid.width / thumbnailGrid.cellWidth) + if (cols < 1) cols = 1 + var marginItems = cols * thumbnailGrid.prefetchMargin + topIndex = Math.max(0, topIndex - marginItems) + bottomIndex = Math.min(thumbnailGrid.count - 1, bottomIndex + marginItems) + + // Log for debugging + if (uiState && uiState.debugMode) { + console.log("Prefetch range:", topIndex, "-", bottomIndex) + } + + // Actually trigger prefetch + if (uiState) { + uiState.gridPrefetchRange(topIndex, bottomIndex) + } + } + + // Trigger prefetch when model count changes (initial load) + onCountChanged: { + if (count > 0) { + // Small delay to let the view layout + prefetchTimer.restart() + } + } + + // Empty state + Text { + anchors.centerIn: parent + visible: thumbnailGrid.count === 0 + text: "No images in this folder" + color: root.isDarkTheme ? "#888888" : "#666666" + font.pixelSize: 16 + } + } + + // Keyboard shortcuts + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Escape) { + // Clear selection or switch to loupe + if (gridViewRoot.selectedPaths.length > 0) { + uiState.gridClearSelection() + } else { + uiState.toggleGridView() + } + event.accepted = true + } else if (event.key === Qt.Key_Backspace) { + // Navigate to parent + var model = thumbnailModel + if (model && model.rowCount() > 0) { + // Check if first item is parent folder + var firstEntry = model.data(model.index(0, 0), 259) // IsFolderRole + var isParent = model.data(model.index(0, 0), 269) // IsParentFolderRole + if (firstEntry && isParent) { + var parentPath = model.data(model.index(0, 0), 257) // FilePathRole + if (parentPath) { + uiState.gridNavigateTo(parentPath) + } + } + } + event.accepted = true + } + } + + // Focus handling + Component.onCompleted: { + gridViewRoot.forceActiveFocus() + // Trigger initial prefetch after a short delay + initialPrefetchTimer.start() + } + + Timer { + id: initialPrefetchTimer + interval: 200 + repeat: false + onTriggered: { + if (thumbnailGrid.count > 0) { + thumbnailGrid.triggerPrefetch() + } + } + } + + Connections { + target: uiState + function onIsGridViewActiveChanged() { + if (uiState.isGridViewActive) { + // Trigger prefetch when grid view becomes active + thumbnailGrid.triggerPrefetch() + gridViewRoot.forceActiveFocus() + } + } + } +} diff --git a/faststack/qml/ThumbnailTile.qml b/faststack/qml/ThumbnailTile.qml new file mode 100644 index 0000000..2575e8f --- /dev/null +++ b/faststack/qml/ThumbnailTile.qml @@ -0,0 +1,307 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +// Tile delegate for thumbnail grid view +Item { + id: tile + + // Properties from model (prefixed to avoid shadowing model roles) + property int tileIndex: 0 + property string tileFilePath: "" + property string tileFileName: "" + property bool tileIsFolder: false + property bool tileIsStacked: false + property bool tileIsUploaded: false + property bool tileIsEdited: false + property bool tileIsRestacked: false + property bool tileIsInBatch: false + property bool tileIsCurrent: false + property string tileThumbnailSource: "" + property var tileFolderStats: null + property bool tileIsSelected: false + property bool tileIsParentFolder: false + + // Configuration + property int tileSize: 180 + property int thumbnailSize: 160 + property int textHeight: 24 + property color textColor: root.isDarkTheme ? "white" : "black" + property color selectedColor: "#4CAF50" + property color currentColor: "#FFD700" // Gold for current image + property color hoverColor: root.isDarkTheme ? "#404040" : "#e0e0e0" + property color backgroundColor: root.isDarkTheme ? "#2d2d2d" : "#fafafa" + + width: tileSize + height: tileSize + textHeight + + // Flag colors for badges + property color stackedColor: "#FF9800" // Orange for stacked (S) + property color uploadedColor: "#4CAF50" // Green for uploaded (U) + property color editedColor: "#FFEB3B" // Yellow for edited (E) + property color restackedColor: "#FF9800" // Orange for restacked (R) + property color batchColor: "#2196F3" // Blue for batch (B) + + // Background + Rectangle { + anchors.fill: parent + color: { + if (tile.tileIsCurrent && !tile.tileIsFolder) { + return Qt.rgba(currentColor.r, currentColor.g, currentColor.b, 0.25) + } else if (tile.tileIsSelected) { + return Qt.rgba(selectedColor.r, selectedColor.g, selectedColor.b, 0.3) + } else if (tileMouseArea.containsMouse) { + return hoverColor + } + return backgroundColor + } + radius: 4 + + // Border - current gets gold, selected gets green + border.color: { + if (tile.tileIsCurrent && !tile.tileIsFolder) { + return currentColor + } else if (tile.tileIsSelected) { + return selectedColor + } + return "transparent" + } + border.width: (tile.tileIsCurrent || tile.tileIsSelected) && !tile.tileIsFolder ? 3 : 0 + } + + // Content column + ColumnLayout { + anchors.fill: parent + anchors.margins: 4 + spacing: 2 + + // Thumbnail container + Item { + Layout.fillWidth: true + Layout.preferredHeight: thumbnailSize + Layout.alignment: Qt.AlignHCenter + + // Thumbnail image + Image { + id: thumbnailImage + anchors.centerIn: parent + width: Math.min(thumbnailSize, parent.width) + height: Math.min(thumbnailSize, parent.height) + fillMode: Image.PreserveAspectFit + source: tile.tileThumbnailSource + asynchronous: true + cache: false + smooth: true + + // Loading placeholder + Rectangle { + anchors.fill: parent + visible: thumbnailImage.status === Image.Loading + color: root.isDarkTheme ? "#3c3c3c" : "#e0e0e0" + + BusyIndicator { + anchors.centerIn: parent + running: thumbnailImage.status === Image.Loading + width: 32 + height: 32 + } + } + } + + // Folder icon overlay (for folders without faststack.json) + Text { + anchors.centerIn: parent + visible: tile.tileIsFolder && !tile.tileIsParentFolder + text: "\uD83D\uDCC1" // Folder emoji + font.pixelSize: 48 + opacity: 0.8 + } + + // Parent folder indicator + Text { + anchors.centerIn: parent + visible: tile.tileIsParentFolder + text: "\u2B06" // Up arrow + font.pixelSize: 48 + color: textColor + opacity: 0.8 + } + + // Flag badges row (bottom-left corner of thumbnail) + Row { + anchors.left: parent.left + anchors.bottom: parent.bottom + anchors.margins: 4 + spacing: 2 + visible: !tile.tileIsFolder + + // Uploaded badge (U) - Green + Rectangle { + visible: tile.tileIsUploaded + width: 18 + height: 18 + radius: 3 + color: uploadedColor + Text { + anchors.centerIn: parent + text: "U" + font.pixelSize: 11 + font.bold: true + color: "white" + } + } + + // Edited badge (E) - Yellow + Rectangle { + visible: tile.tileIsEdited + width: 18 + height: 18 + radius: 3 + color: editedColor + Text { + anchors.centerIn: parent + text: "E" + font.pixelSize: 11 + font.bold: true + color: "black" + } + } + + // Restacked badge (R) - Orange + Rectangle { + visible: tile.tileIsRestacked + width: 18 + height: 18 + radius: 3 + color: restackedColor + Text { + anchors.centerIn: parent + text: "R" + font.pixelSize: 11 + font.bold: true + color: "white" + } + } + + // Batch badge (B) - Blue + Rectangle { + visible: tile.tileIsInBatch + width: 18 + height: 18 + radius: 3 + color: batchColor + Text { + anchors.centerIn: parent + text: "B" + font.pixelSize: 11 + font.bold: true + color: "white" + } + } + + // Stacked badge (S) - Orange (same as restacked but different meaning) + Rectangle { + visible: tile.tileIsStacked && !tile.tileIsRestacked // Don't show S if R is shown + width: 18 + height: 18 + radius: 3 + color: stackedColor + Text { + anchors.centerIn: parent + text: "S" + font.pixelSize: 11 + font.bold: true + color: "white" + } + } + } + + // Folder stats overlay (for folders with faststack.json) + Rectangle { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: tile.tileFolderStats && tile.tileFolderStats.total_images > 0 ? 36 : 0 + color: Qt.rgba(0, 0, 0, 0.7) + visible: tile.tileIsFolder && tile.tileFolderStats && tile.tileFolderStats.total_images > 0 + + Column { + anchors.centerIn: parent + spacing: 2 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: tile.tileFolderStats ? tile.tileFolderStats.total_images + " images" : "" + font.pixelSize: 10 + font.bold: true + color: "white" + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: 6 + visible: tile.tileFolderStats && (tile.tileFolderStats.stacked_count > 0 || tile.tileFolderStats.uploaded_count > 0 || tile.tileFolderStats.edited_count > 0) + + Text { + visible: tile.tileFolderStats && tile.tileFolderStats.stacked_count > 0 + text: "S:" + (tile.tileFolderStats ? tile.tileFolderStats.stacked_count : 0) + font.pixelSize: 9 + color: "#FF9800" + } + Text { + visible: tile.tileFolderStats && tile.tileFolderStats.uploaded_count > 0 + text: "U:" + (tile.tileFolderStats ? tile.tileFolderStats.uploaded_count : 0) + font.pixelSize: 9 + color: "#4CAF50" + } + Text { + visible: tile.tileFolderStats && tile.tileFolderStats.edited_count > 0 + text: "E:" + (tile.tileFolderStats ? tile.tileFolderStats.edited_count : 0) + font.pixelSize: 9 + color: "#FFEB3B" + } + } + } + } + } + + // Filename text + Text { + Layout.fillWidth: true + Layout.preferredHeight: textHeight + text: tile.tileIsParentFolder ? "(Parent Folder)" : tile.tileFileName + color: textColor + font.pixelSize: 11 + elide: Text.ElideMiddle + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + + // Mouse area for interactions + MouseArea { + id: tileMouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton + + onClicked: function(mouse) { + if (tile.tileIsFolder) { + // Navigate into folder (or parent) + uiState.gridNavigateTo(tile.tileFilePath) + } else { + // Handle selection or opening + var hasShift = (mouse.modifiers & Qt.ShiftModifier) + var hasCtrl = (mouse.modifiers & Qt.ControlModifier) + + if (hasShift || hasCtrl) { + // Batch selection + uiState.gridSelectIndex(tile.tileIndex, hasShift, hasCtrl) + } else { + // Open in loupe view + uiState.gridOpenIndex(tile.tileIndex) + } + } + } + } +} diff --git a/faststack/tests/thumbnail_view/__init__.py b/faststack/tests/thumbnail_view/__init__.py new file mode 100644 index 0000000..2c3aa04 --- /dev/null +++ b/faststack/tests/thumbnail_view/__init__.py @@ -0,0 +1 @@ +"""Tests for thumbnail_view package.""" diff --git a/faststack/tests/thumbnail_view/test_folder_stats.py b/faststack/tests/thumbnail_view/test_folder_stats.py new file mode 100644 index 0000000..033f6a5 --- /dev/null +++ b/faststack/tests/thumbnail_view/test_folder_stats.py @@ -0,0 +1,177 @@ +"""Tests for folder_stats module.""" + +import json +import pytest +from pathlib import Path +from faststack.thumbnail_view.folder_stats import ( + FolderStats, + read_folder_stats, + clear_stats_cache, + _stats_cache, +) + + +@pytest.fixture +def temp_folder(tmp_path): + """Create a temporary folder for testing.""" + return tmp_path + + +@pytest.fixture(autouse=True) +def clear_cache(): + """Clear stats cache before each test.""" + clear_stats_cache() + yield + clear_stats_cache() + + +class TestFolderStats: + """Tests for FolderStats dataclass.""" + + def test_folder_stats_creation(self): + """Test FolderStats can be created with valid values.""" + stats = FolderStats( + total_images=100, + stacked_count=25, + uploaded_count=50, + edited_count=10, + ) + assert stats.total_images == 100 + assert stats.stacked_count == 25 + assert stats.uploaded_count == 50 + assert stats.edited_count == 10 + + +class TestReadFolderStats: + """Tests for read_folder_stats function.""" + + def test_read_valid_faststack_json(self, temp_folder): + """Test reading a valid faststack.json file.""" + json_path = temp_folder / "faststack.json" + data = { + "version": 2, + "entries": { + "IMG_001": {"stacked": True, "uploaded": False, "edited": False}, + "IMG_002": {"stacked": False, "uploaded": True, "edited": True}, + "IMG_003": {"stacked": True, "uploaded": True, "edited": False}, + } + } + json_path.write_text(json.dumps(data)) + + stats = read_folder_stats(temp_folder) + + assert stats is not None + assert stats.total_images == 3 + assert stats.stacked_count == 2 + assert stats.uploaded_count == 2 + assert stats.edited_count == 1 + + def test_read_missing_faststack_json(self, temp_folder): + """Test reading from a folder without faststack.json.""" + stats = read_folder_stats(temp_folder) + assert stats is None + + def test_read_empty_entries(self, temp_folder): + """Test reading a faststack.json with no entries.""" + json_path = temp_folder / "faststack.json" + data = {"version": 2, "entries": {}} + json_path.write_text(json.dumps(data)) + + stats = read_folder_stats(temp_folder) + + assert stats is not None + assert stats.total_images == 0 + assert stats.stacked_count == 0 + + def test_read_corrupt_json(self, temp_folder): + """Test reading a corrupt faststack.json file.""" + json_path = temp_folder / "faststack.json" + json_path.write_text("{ invalid json }") + + stats = read_folder_stats(temp_folder) + assert stats is None + + def test_read_missing_keys(self, temp_folder): + """Test reading faststack.json with missing keys (old format).""" + json_path = temp_folder / "faststack.json" + data = { + "entries": { + "IMG_001": {}, # No flags + "IMG_002": {"stacked": True}, # Only stacked + } + } + json_path.write_text(json.dumps(data)) + + stats = read_folder_stats(temp_folder) + + assert stats is not None + assert stats.total_images == 2 + assert stats.stacked_count == 1 + assert stats.uploaded_count == 0 + assert stats.edited_count == 0 + + def test_caching_by_mtime(self, temp_folder): + """Test that results are cached by mtime_ns.""" + json_path = temp_folder / "faststack.json" + data = {"version": 2, "entries": {"IMG_001": {"stacked": True}}} + json_path.write_text(json.dumps(data)) + + # First read + stats1 = read_folder_stats(temp_folder) + assert stats1 is not None + assert stats1.stacked_count == 1 + + # Check cache was populated + assert len(_stats_cache) == 1 + + # Second read should use cache + stats2 = read_folder_stats(temp_folder) + assert stats2 is stats1 # Same object from cache + + def test_cache_invalidation_on_mtime_change(self, temp_folder): + """Test that cache is invalidated when file mtime changes.""" + json_path = temp_folder / "faststack.json" + data = {"version": 2, "entries": {"IMG_001": {"stacked": True}}} + json_path.write_text(json.dumps(data)) + + # First read + stats1 = read_folder_stats(temp_folder) + assert stats1.stacked_count == 1 + + # Modify file + import time + time.sleep(0.01) # Ensure different mtime + data["entries"]["IMG_002"] = {"stacked": True} + json_path.write_text(json.dumps(data)) + + # Second read should get new data + stats2 = read_folder_stats(temp_folder) + assert stats2 is not stats1 + assert stats2.stacked_count == 2 + + def test_invalid_entries_format(self, temp_folder): + """Test reading faststack.json with invalid entries format.""" + json_path = temp_folder / "faststack.json" + data = {"version": 2, "entries": "not a dict"} + json_path.write_text(json.dumps(data)) + + stats = read_folder_stats(temp_folder) + assert stats is None + + def test_entry_with_non_dict_value(self, temp_folder): + """Test reading entries where value is not a dict.""" + json_path = temp_folder / "faststack.json" + data = { + "version": 2, + "entries": { + "IMG_001": {"stacked": True}, + "IMG_002": "invalid", # Should be skipped + } + } + json_path.write_text(json.dumps(data)) + + stats = read_folder_stats(temp_folder) + + assert stats is not None + assert stats.total_images == 2 # Both entries counted + assert stats.stacked_count == 1 # Only valid entry counted diff --git a/faststack/tests/thumbnail_view/test_model.py b/faststack/tests/thumbnail_view/test_model.py new file mode 100644 index 0000000..7cd5d01 --- /dev/null +++ b/faststack/tests/thumbnail_view/test_model.py @@ -0,0 +1,331 @@ +"""Tests for ThumbnailModel.""" + +import pytest +from pathlib import Path +from unittest.mock import MagicMock, patch +from PySide6.QtCore import Qt + +from faststack.thumbnail_view.model import ThumbnailModel, ThumbnailEntry, _compute_path_hash + + +@pytest.fixture +def temp_folder(tmp_path): + """Create a temporary folder structure for testing.""" + # Create some test files + (tmp_path / "image1.jpg").touch() + (tmp_path / "image2.jpg").touch() + (tmp_path / "image3.png").touch() + + # Create a subfolder + subfolder = tmp_path / "subfolder" + subfolder.mkdir() + (subfolder / "sub_image.jpg").touch() + + return tmp_path + + +@pytest.fixture +def model(temp_folder): + """Create a ThumbnailModel instance.""" + model = ThumbnailModel( + base_directory=temp_folder, + current_directory=temp_folder, + get_metadata_callback=None, + thumbnail_size=200, + ) + return model + + +class TestThumbnailEntry: + """Tests for ThumbnailEntry dataclass.""" + + def test_entry_creation(self, temp_folder): + """Test creating a ThumbnailEntry.""" + entry = ThumbnailEntry( + path=temp_folder / "test.jpg", + name="test.jpg", + is_folder=False, + is_stacked=True, + is_uploaded=False, + is_edited=True, + mtime_ns=1234567890, + ) + assert entry.name == "test.jpg" + assert entry.is_folder is False + assert entry.is_stacked is True + assert entry.thumb_rev == 0 + + +class TestComputePathHash: + """Tests for _compute_path_hash function.""" + + def test_hash_is_stable(self, temp_folder): + """Test that hash is stable for same path.""" + path = temp_folder / "test.jpg" + hash1 = _compute_path_hash(path) + hash2 = _compute_path_hash(path) + assert hash1 == hash2 + + def test_hash_is_16_chars(self, temp_folder): + """Test that hash is 16 characters long.""" + path = temp_folder / "test.jpg" + hash_val = _compute_path_hash(path) + assert len(hash_val) == 16 + + +class TestThumbnailModel: + """Tests for ThumbnailModel.""" + + def test_model_creation(self, model, temp_folder): + """Test model is created correctly.""" + assert model.current_directory == temp_folder.resolve() + assert model.base_directory == temp_folder.resolve() + assert model.rowCount() == 0 # Not refreshed yet + + @patch('faststack.thumbnail_view.model.find_images') + def test_refresh_populates_entries(self, mock_find_images, model, temp_folder): + """Test that refresh populates the model.""" + from faststack.models import ImageFile + + # Mock find_images to return test images + mock_find_images.return_value = [ + ImageFile(path=temp_folder / "image1.jpg", timestamp=1.0), + ImageFile(path=temp_folder / "image2.jpg", timestamp=2.0), + ] + + model.refresh() + + # Should have 1 folder + 2 images (no parent folder since at base) + assert model.rowCount() >= 2 + + @patch('faststack.thumbnail_view.model.find_images') + def test_folders_sorted_first(self, mock_find_images, model, temp_folder): + """Test that folders appear before images.""" + from faststack.models import ImageFile + + mock_find_images.return_value = [ + ImageFile(path=temp_folder / "image1.jpg", timestamp=1.0), + ] + + model.refresh() + + # Check folder is first (if any) + if model.rowCount() > 1: + entry0 = model.get_entry(0) + entry1 = model.get_entry(1) + if entry0 and entry1: + # If first is folder and second is file, order is correct + if entry0.is_folder and not entry1.is_folder: + assert True + elif not entry0.is_folder and entry1.is_folder: + pytest.fail("Folder should come before file") + + def test_role_names(self, model): + """Test that roleNames returns expected roles.""" + roles = model.roleNames() + assert b"filePath" in roles.values() + assert b"fileName" in roles.values() + assert b"isFolder" in roles.values() + assert b"isStacked" in roles.values() + assert b"isUploaded" in roles.values() + assert b"isEdited" in roles.values() + assert b"thumbnailSource" in roles.values() + assert b"isSelected" in roles.values() + + @patch('faststack.thumbnail_view.model.find_images') + def test_parent_folder_at_subdirectory(self, mock_find_images, temp_folder): + """Test that parent folder entry appears when not at base.""" + from faststack.models import ImageFile + + subfolder = temp_folder / "subfolder" + + # Create model at subfolder + model = ThumbnailModel( + base_directory=temp_folder, + current_directory=subfolder, + get_metadata_callback=None, + ) + + mock_find_images.return_value = [ + ImageFile(path=subfolder / "sub_image.jpg", timestamp=1.0), + ] + + model.refresh() + + # First entry should be parent folder + first_entry = model.get_entry(0) + assert first_entry is not None + assert first_entry.name == ".." + assert first_entry.is_folder is True + + @patch('faststack.thumbnail_view.model.find_images') + def test_no_parent_folder_at_base(self, mock_find_images, model, temp_folder): + """Test that no parent folder entry when at base directory.""" + from faststack.models import ImageFile + + mock_find_images.return_value = [] + + model.refresh() + + # No ".." entry when at base + for i in range(model.rowCount()): + entry = model.get_entry(i) + if entry: + assert entry.name != ".." + + +class TestThumbnailModelSelection: + """Tests for selection functionality.""" + + @patch('faststack.thumbnail_view.model.find_images') + def test_select_single(self, mock_find_images, model, temp_folder): + """Test selecting a single image.""" + from faststack.models import ImageFile + + mock_find_images.return_value = [ + ImageFile(path=temp_folder / "image1.jpg", timestamp=1.0), + ImageFile(path=temp_folder / "image2.jpg", timestamp=2.0), + ] + + model.refresh() + + # Find first non-folder index + img_idx = None + for i in range(model.rowCount()): + entry = model.get_entry(i) + if entry and not entry.is_folder: + img_idx = i + break + + if img_idx is not None: + model.select_index(img_idx, shift=False, ctrl=False) + selected = model.get_selected_paths() + assert len(selected) == 1 + + @patch('faststack.thumbnail_view.model.find_images') + def test_ctrl_click_toggle(self, mock_find_images, model, temp_folder): + """Test Ctrl+click toggles selection.""" + from faststack.models import ImageFile + + mock_find_images.return_value = [ + ImageFile(path=temp_folder / "image1.jpg", timestamp=1.0), + ImageFile(path=temp_folder / "image2.jpg", timestamp=2.0), + ] + + model.refresh() + + # Find image indices + img_indices = [] + for i in range(model.rowCount()): + entry = model.get_entry(i) + if entry and not entry.is_folder: + img_indices.append(i) + + if len(img_indices) >= 2: + # Select first + model.select_index(img_indices[0], shift=False, ctrl=False) + # Ctrl+click second + model.select_index(img_indices[1], shift=False, ctrl=True) + assert len(model.get_selected_paths()) == 2 + + # Ctrl+click first again to deselect + model.select_index(img_indices[0], shift=False, ctrl=True) + assert len(model.get_selected_paths()) == 1 + + @patch('faststack.thumbnail_view.model.find_images') + def test_clear_selection(self, mock_find_images, model, temp_folder): + """Test clearing selection.""" + from faststack.models import ImageFile + + mock_find_images.return_value = [ + ImageFile(path=temp_folder / "image1.jpg", timestamp=1.0), + ] + + model.refresh() + + # Find and select an image + for i in range(model.rowCount()): + entry = model.get_entry(i) + if entry and not entry.is_folder: + model.select_index(i, shift=False, ctrl=False) + break + + assert len(model.get_selected_paths()) == 1 + + model.clear_selection() + assert len(model.get_selected_paths()) == 0 + + @patch('faststack.thumbnail_view.model.find_images') + def test_cannot_select_folders(self, mock_find_images, model, temp_folder): + """Test that folders cannot be selected.""" + from faststack.models import ImageFile + + mock_find_images.return_value = [] + + model.refresh() + + # Try to select a folder + for i in range(model.rowCount()): + entry = model.get_entry(i) + if entry and entry.is_folder: + model.select_index(i, shift=False, ctrl=False) + break + + # Selection should be empty + assert len(model.get_selected_paths()) == 0 + + +class TestThumbnailModelNavigation: + """Tests for navigation functionality.""" + + @patch('faststack.thumbnail_view.model.find_images') + def test_navigate_to_subfolder(self, mock_find_images, model, temp_folder): + """Test navigating to a subfolder.""" + from faststack.models import ImageFile + + subfolder = temp_folder / "subfolder" + mock_find_images.return_value = [] + + model.navigate_to(subfolder) + + assert model.current_directory == subfolder.resolve() + + @patch('faststack.thumbnail_view.model.find_images') + def test_cannot_navigate_outside_base(self, mock_find_images, model, temp_folder): + """Test that navigation outside base directory is blocked.""" + from faststack.models import ImageFile + + mock_find_images.return_value = [] + + # Try to navigate to parent of base + model.navigate_to(temp_folder.parent) + + # Should still be at base + assert model.current_directory == temp_folder.resolve() + + @patch('faststack.thumbnail_view.model.find_images') + def test_navigation_clears_selection(self, mock_find_images, model, temp_folder): + """Test that navigation clears selection.""" + from faststack.models import ImageFile + + mock_find_images.return_value = [ + ImageFile(path=temp_folder / "image1.jpg", timestamp=1.0), + ] + + model.refresh() + + # Select an image + for i in range(model.rowCount()): + entry = model.get_entry(i) + if entry and not entry.is_folder: + model.select_index(i, shift=False, ctrl=False) + break + + assert len(model.get_selected_paths()) >= 0 # May or may not have selection + + # Navigate + subfolder = temp_folder / "subfolder" + model.navigate_to(subfolder) + + # Selection should be cleared + assert len(model.get_selected_paths()) == 0 diff --git a/faststack/tests/thumbnail_view/test_prefetcher.py b/faststack/tests/thumbnail_view/test_prefetcher.py new file mode 100644 index 0000000..3d1b45b --- /dev/null +++ b/faststack/tests/thumbnail_view/test_prefetcher.py @@ -0,0 +1,311 @@ +"""Tests for ThumbnailPrefetcher and ThumbnailCache.""" + +import pytest +import time +from pathlib import Path +from unittest.mock import MagicMock, patch +from PIL import Image +import numpy as np + +from faststack.thumbnail_view.prefetcher import ( + ThumbnailPrefetcher, + ThumbnailCache, + _compute_path_hash, +) + + +@pytest.fixture +def temp_folder(tmp_path): + """Create a temporary folder with test images.""" + return tmp_path + + +@pytest.fixture +def test_image(temp_folder): + """Create a test JPEG image.""" + img_path = temp_folder / "test.jpg" + img = Image.new("RGB", (400, 300), color="red") + img.save(img_path, "JPEG") + return img_path + + +@pytest.fixture +def cache(): + """Create a test cache.""" + return ThumbnailCache(max_bytes=1024 * 1024, max_items=100) + + +@pytest.fixture +def prefetcher(cache): + """Create a test prefetcher.""" + callback = MagicMock() + pf = ThumbnailPrefetcher( + cache=cache, + on_ready_callback=callback, + max_workers=2, + target_size=200, + ) + yield pf + pf.shutdown() + + +class TestThumbnailCache: + """Tests for ThumbnailCache.""" + + def test_put_and_get(self, cache): + """Test basic put and get operations.""" + cache.put("key1", b"value1") + assert cache.get("key1") == b"value1" + + def test_get_missing_key(self, cache): + """Test getting a non-existent key.""" + assert cache.get("nonexistent") is None + + def test_lru_eviction_by_count(self): + """Test LRU eviction when max_items is reached.""" + cache = ThumbnailCache(max_bytes=1024 * 1024, max_items=3) + + cache.put("key1", b"v1") + cache.put("key2", b"v2") + cache.put("key3", b"v3") + cache.put("key4", b"v4") # Should evict key1 + + assert cache.get("key1") is None + assert cache.get("key2") is not None + assert cache.get("key3") is not None + assert cache.get("key4") is not None + + def test_lru_eviction_by_bytes(self): + """Test LRU eviction when max_bytes is reached.""" + cache = ThumbnailCache(max_bytes=100, max_items=1000) + + cache.put("key1", b"x" * 40) + cache.put("key2", b"y" * 40) + cache.put("key3", b"z" * 40) # Should evict key1 + + assert cache.get("key1") is None + assert cache.get("key2") is not None + assert cache.get("key3") is not None + + def test_lru_order_updated_on_get(self, cache): + """Test that accessing an item moves it to end of LRU.""" + cache.put("key1", b"v1") + cache.put("key2", b"v2") + + # Access key1 to make it more recently used + cache.get("key1") + + # Add enough items to trigger eviction + cache._max_items = 2 + cache.put("key3", b"v3") + + # key2 should be evicted (oldest), key1 should remain + assert cache.get("key1") is not None + assert cache.get("key2") is None + + def test_clear(self, cache): + """Test clearing the cache.""" + cache.put("key1", b"v1") + cache.put("key2", b"v2") + + cache.clear() + + assert cache.get("key1") is None + assert cache.get("key2") is None + assert cache.size == 0 + assert cache.bytes_used == 0 + + def test_size_and_bytes_used(self, cache): + """Test size and bytes_used properties.""" + cache.put("key1", b"12345") + cache.put("key2", b"67890") + + assert cache.size == 2 + assert cache.bytes_used == 10 + + def test_update_existing_key(self, cache): + """Test updating an existing key.""" + cache.put("key1", b"old") + cache.put("key1", b"new_value") + + assert cache.get("key1") == b"new_value" + assert cache.size == 1 + + +class TestThumbnailPrefetcher: + """Tests for ThumbnailPrefetcher.""" + + def test_prefetcher_creation(self, prefetcher, cache): + """Test prefetcher is created correctly.""" + assert prefetcher._cache is cache + assert prefetcher._target_size == 200 + + def test_submit_schedules_job(self, prefetcher, test_image, cache): + """Test that submit schedules a decode job.""" + mtime_ns = test_image.stat().st_mtime_ns + + result = prefetcher.submit(test_image, mtime_ns) + assert result is True + + # Wait for job to complete + time.sleep(0.5) + + # Check cache was populated + path_hash = _compute_path_hash(test_image) + cache_key = f"200/{path_hash}/{mtime_ns}" + assert cache.get(cache_key) is not None + + def test_submit_skips_if_cached(self, prefetcher, test_image, cache): + """Test that submit skips if already cached.""" + mtime_ns = test_image.stat().st_mtime_ns + path_hash = _compute_path_hash(test_image) + cache_key = f"200/{path_hash}/{mtime_ns}" + + # Pre-populate cache + cache.put(cache_key, b"cached_data") + + result = prefetcher.submit(test_image, mtime_ns) + assert result is False + + def test_submit_deduplicates_inflight(self, prefetcher, test_image, cache): + """Test that duplicate in-flight jobs are skipped.""" + mtime_ns = test_image.stat().st_mtime_ns + + result1 = prefetcher.submit(test_image, mtime_ns) + result2 = prefetcher.submit(test_image, mtime_ns) + + assert result1 is True + assert result2 is False + + def test_callback_called_on_complete(self, cache, test_image): + """Test that callback is called when decode completes.""" + callback = MagicMock() + prefetcher = ThumbnailPrefetcher( + cache=cache, + on_ready_callback=callback, + max_workers=1, + ) + + try: + mtime_ns = test_image.stat().st_mtime_ns + prefetcher.submit(test_image, mtime_ns) + + # Wait for completion + time.sleep(0.5) + + # Callback should have been called + callback.assert_called_once() + call_arg = callback.call_args[0][0] + assert "200/" in call_arg + finally: + prefetcher.shutdown() + + def test_cancel_all(self, prefetcher, test_image): + """Test canceling all pending jobs.""" + mtime_ns = test_image.stat().st_mtime_ns + + prefetcher.submit(test_image, mtime_ns) + prefetcher.cancel_all() + + assert len(prefetcher._inflight) == 0 + assert len(prefetcher._futures) == 0 + + +class TestThumbnailDecode: + """Tests for thumbnail decoding functionality.""" + + def test_decode_applies_exif_orientation(self, cache, temp_folder): + """Test that EXIF orientation is applied during decode.""" + # Create an image with EXIF orientation + img_path = temp_folder / "oriented.jpg" + img = Image.new("RGB", (400, 200), color="blue") + + # Save with EXIF orientation (rotated 90 CW) + from PIL.ExifTags import TAGS + exif_dict = img.getexif() + exif_dict[274] = 6 # Orientation tag = 6 (90 CW) + + img.save(img_path, "JPEG", exif=exif_dict) + + callback = MagicMock() + prefetcher = ThumbnailPrefetcher( + cache=cache, + on_ready_callback=callback, + max_workers=1, + target_size=100, + ) + + try: + mtime_ns = img_path.stat().st_mtime_ns + prefetcher.submit(img_path, mtime_ns) + + # Wait for completion + time.sleep(0.5) + + # Get cached thumbnail + path_hash = _compute_path_hash(img_path) + cache_key = f"100/{path_hash}/{mtime_ns}" + cached_bytes = cache.get(cache_key) + + assert cached_bytes is not None + + # Verify thumbnail was created (detailed orientation check would require + # decoding and checking dimensions, which is complex for a unit test) + assert len(cached_bytes) > 0 + finally: + prefetcher.shutdown() + + def test_decode_handles_png(self, cache, temp_folder): + """Test that PNG files can be decoded.""" + img_path = temp_folder / "test.png" + img = Image.new("RGB", (300, 300), color="green") + img.save(img_path, "PNG") + + callback = MagicMock() + prefetcher = ThumbnailPrefetcher( + cache=cache, + on_ready_callback=callback, + max_workers=1, + ) + + try: + mtime_ns = img_path.stat().st_mtime_ns + prefetcher.submit(img_path, mtime_ns) + + # Wait for completion + time.sleep(0.5) + + path_hash = _compute_path_hash(img_path) + cache_key = f"200/{path_hash}/{mtime_ns}" + assert cache.get(cache_key) is not None + finally: + prefetcher.shutdown() + + def test_decode_handles_corrupt_file(self, cache, temp_folder): + """Test that corrupt files are handled gracefully.""" + img_path = temp_folder / "corrupt.jpg" + img_path.write_bytes(b"not a valid jpeg") + + callback = MagicMock() + prefetcher = ThumbnailPrefetcher( + cache=cache, + on_ready_callback=callback, + max_workers=1, + ) + + try: + mtime_ns = img_path.stat().st_mtime_ns + prefetcher.submit(img_path, mtime_ns) + + # Wait for completion + time.sleep(0.5) + + # Cache should not have the corrupt file + path_hash = _compute_path_hash(img_path) + cache_key = f"200/{path_hash}/{mtime_ns}" + assert cache.get(cache_key) is None + + # Callback should not have been called + callback.assert_not_called() + finally: + prefetcher.shutdown() diff --git a/faststack/tests/thumbnail_view/test_provider.py b/faststack/tests/thumbnail_view/test_provider.py new file mode 100644 index 0000000..e56f500 --- /dev/null +++ b/faststack/tests/thumbnail_view/test_provider.py @@ -0,0 +1,102 @@ +"""Tests for PathResolver (ThumbnailProvider requires Qt GUI).""" + +import pytest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from faststack.thumbnail_view.provider import PathResolver + + +class TestPathResolver: + """Tests for PathResolver.""" + + def test_register_and_resolve(self): + """Test registering and resolving paths.""" + resolver = PathResolver() + path = Path("/test/image.jpg") + path_hash = "abc123" + + resolver.register(path, path_hash) + resolved = resolver.resolve(path_hash) + + assert resolved == path + + def test_resolve_unknown_hash(self): + """Test resolving unknown hash returns None.""" + resolver = PathResolver() + assert resolver.resolve("unknown") is None + + def test_clear(self): + """Test clearing the resolver.""" + resolver = PathResolver() + resolver.register(Path("/test/image.jpg"), "abc123") + + resolver.clear() + + assert resolver.resolve("abc123") is None + + def test_multiple_registrations(self): + """Test registering multiple paths.""" + resolver = PathResolver() + + resolver.register(Path("/test/image1.jpg"), "hash1") + resolver.register(Path("/test/image2.jpg"), "hash2") + resolver.register(Path("/test/image3.jpg"), "hash3") + + assert resolver.resolve("hash1") == Path("/test/image1.jpg") + assert resolver.resolve("hash2") == Path("/test/image2.jpg") + assert resolver.resolve("hash3") == Path("/test/image3.jpg") + + def test_overwrite_existing_hash(self): + """Test that registering with same hash overwrites.""" + resolver = PathResolver() + + resolver.register(Path("/test/old.jpg"), "hash1") + resolver.register(Path("/test/new.jpg"), "hash1") + + assert resolver.resolve("hash1") == Path("/test/new.jpg") + + def test_update_from_model(self): + """Test updating resolver from a model.""" + resolver = PathResolver() + + # Mock model + mock_model = MagicMock() + mock_model.rowCount.return_value = 2 + + entry1 = MagicMock() + entry1.is_folder = False + entry1.path = Path("/test/image1.jpg") + + entry2 = MagicMock() + entry2.is_folder = True # Folders should be skipped + entry2.path = Path("/test/folder") + + mock_model.get_entry.side_effect = [entry1, entry2] + + resolver.update_from_model(mock_model) + + # Should have registered the non-folder entry + assert len(resolver._hash_to_path) == 1 + + def test_update_from_model_clears_first(self): + """Test that update_from_model clears existing registrations.""" + resolver = PathResolver() + resolver.register(Path("/old/path.jpg"), "oldhash") + + # Mock model with empty entries + mock_model = MagicMock() + mock_model.rowCount.return_value = 0 + + resolver.update_from_model(mock_model) + + # Old registration should be cleared + assert resolver.resolve("oldhash") is None + + +# Note: ThumbnailProvider tests are skipped because they require a running +# Qt GUI application (QApplication). The provider creates QPixmap objects +# which require a display connection. +# +# To test ThumbnailProvider functionality, use integration tests with +# pytest-qt and a proper QApplication fixture. diff --git a/faststack/tests/thumbnail_view/test_selection.py b/faststack/tests/thumbnail_view/test_selection.py new file mode 100644 index 0000000..fc24c5b --- /dev/null +++ b/faststack/tests/thumbnail_view/test_selection.py @@ -0,0 +1,303 @@ +"""Tests for selection functionality in ThumbnailModel.""" + +import pytest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from faststack.thumbnail_view.model import ThumbnailModel, ThumbnailEntry + + +@pytest.fixture +def temp_folder(tmp_path): + """Create a temporary folder structure for testing.""" + # Create some test files + for i in range(5): + (tmp_path / f"image{i}.jpg").touch() + return tmp_path + + +@pytest.fixture +def model_with_images(temp_folder): + """Create a ThumbnailModel with mock images.""" + model = ThumbnailModel( + base_directory=temp_folder, + current_directory=temp_folder, + get_metadata_callback=None, + thumbnail_size=200, + ) + + # Manually populate entries for testing + model._entries = [ + ThumbnailEntry(path=temp_folder / "image0.jpg", name="image0.jpg", is_folder=False, mtime_ns=1000), + ThumbnailEntry(path=temp_folder / "image1.jpg", name="image1.jpg", is_folder=False, mtime_ns=1001), + ThumbnailEntry(path=temp_folder / "image2.jpg", name="image2.jpg", is_folder=False, mtime_ns=1002), + ThumbnailEntry(path=temp_folder / "image3.jpg", name="image3.jpg", is_folder=False, mtime_ns=1003), + ThumbnailEntry(path=temp_folder / "image4.jpg", name="image4.jpg", is_folder=False, mtime_ns=1004), + ] + + return model + + +class TestPlainClick: + """Tests for plain click selection behavior.""" + + def test_plain_click_selects_single(self, model_with_images): + """Test that plain click selects only the clicked item.""" + model_with_images.select_index(2, shift=False, ctrl=False) + + selected = model_with_images.get_selected_paths() + assert len(selected) == 1 + assert selected[0].name == "image2.jpg" + + def test_plain_click_clears_previous_selection(self, model_with_images): + """Test that plain click clears previous selection.""" + # Select multiple items first + model_with_images.select_index(1, shift=False, ctrl=False) + model_with_images.select_index(2, shift=False, ctrl=True) + model_with_images.select_index(3, shift=False, ctrl=True) + + assert len(model_with_images.get_selected_paths()) == 3 + + # Plain click should clear and select only one + model_with_images.select_index(0, shift=False, ctrl=False) + + selected = model_with_images.get_selected_paths() + assert len(selected) == 1 + assert selected[0].name == "image0.jpg" + + +class TestCtrlClick: + """Tests for Ctrl+click selection behavior.""" + + def test_ctrl_click_adds_to_selection(self, model_with_images): + """Test that Ctrl+click adds to existing selection.""" + model_with_images.select_index(1, shift=False, ctrl=False) + model_with_images.select_index(3, shift=False, ctrl=True) + + selected = model_with_images.get_selected_paths() + assert len(selected) == 2 + + names = [p.name for p in selected] + assert "image1.jpg" in names + assert "image3.jpg" in names + + def test_ctrl_click_toggles_off(self, model_with_images): + """Test that Ctrl+click on selected item deselects it.""" + model_with_images.select_index(1, shift=False, ctrl=False) + model_with_images.select_index(2, shift=False, ctrl=True) + model_with_images.select_index(3, shift=False, ctrl=True) + + assert len(model_with_images.get_selected_paths()) == 3 + + # Ctrl+click on already selected item + model_with_images.select_index(2, shift=False, ctrl=True) + + selected = model_with_images.get_selected_paths() + assert len(selected) == 2 + + names = [p.name for p in selected] + assert "image2.jpg" not in names + + def test_ctrl_click_non_contiguous(self, model_with_images): + """Test Ctrl+click can select non-contiguous items.""" + model_with_images.select_index(0, shift=False, ctrl=False) + model_with_images.select_index(2, shift=False, ctrl=True) + model_with_images.select_index(4, shift=False, ctrl=True) + + selected = model_with_images.get_selected_paths() + assert len(selected) == 3 + + names = [p.name for p in selected] + assert "image0.jpg" in names + assert "image2.jpg" in names + assert "image4.jpg" in names + assert "image1.jpg" not in names + assert "image3.jpg" not in names + + +class TestShiftClick: + """Tests for Shift+click selection behavior.""" + + def test_shift_click_selects_range(self, model_with_images): + """Test that Shift+click selects a contiguous range.""" + model_with_images.select_index(1, shift=False, ctrl=False) + model_with_images.select_index(4, shift=True, ctrl=False) + + selected = model_with_images.get_selected_paths() + assert len(selected) == 4 # images 1, 2, 3, 4 + + names = [p.name for p in selected] + assert "image1.jpg" in names + assert "image2.jpg" in names + assert "image3.jpg" in names + assert "image4.jpg" in names + + def test_shift_click_range_backwards(self, model_with_images): + """Test Shift+click works when clicking backwards.""" + model_with_images.select_index(4, shift=False, ctrl=False) + model_with_images.select_index(1, shift=True, ctrl=False) + + selected = model_with_images.get_selected_paths() + assert len(selected) == 4 # images 1, 2, 3, 4 + + def test_shift_click_adds_to_existing(self, model_with_images): + """Test Shift+click adds to existing selection.""" + model_with_images.select_index(0, shift=False, ctrl=False) + model_with_images.select_index(2, shift=True, ctrl=False) + + selected = model_with_images.get_selected_paths() + assert len(selected) == 3 # images 0, 1, 2 + + def test_shift_click_without_anchor(self, model_with_images): + """Test Shift+click when no previous selection.""" + # Clear any existing selection + model_with_images.clear_selection() + model_with_images._last_selected_index = None + + # Shift+click without anchor should just select the item + model_with_images.select_index(2, shift=True, ctrl=False) + + # Should select from 0 to 2 or just the item depending on implementation + # In our implementation, if no anchor, it just selects the single item + selected = model_with_images.get_selected_paths() + assert len(selected) >= 1 + + +class TestFolderSelection: + """Tests for folder selection behavior.""" + + def test_cannot_select_folder(self, temp_folder): + """Test that folders cannot be selected.""" + model = ThumbnailModel( + base_directory=temp_folder, + current_directory=temp_folder, + get_metadata_callback=None, + ) + + # Add a folder entry + model._entries = [ + ThumbnailEntry(path=temp_folder / "subfolder", name="subfolder", is_folder=True), + ThumbnailEntry(path=temp_folder / "image.jpg", name="image.jpg", is_folder=False), + ] + + # Try to select folder + model.select_index(0, shift=False, ctrl=False) + + # Should have no selection + assert len(model.get_selected_paths()) == 0 + + def test_shift_click_skips_folders(self, temp_folder): + """Test that Shift+click range selection skips folders.""" + model = ThumbnailModel( + base_directory=temp_folder, + current_directory=temp_folder, + get_metadata_callback=None, + ) + + # Add mixed entries + model._entries = [ + ThumbnailEntry(path=temp_folder / "image0.jpg", name="image0.jpg", is_folder=False), + ThumbnailEntry(path=temp_folder / "subfolder", name="subfolder", is_folder=True), + ThumbnailEntry(path=temp_folder / "image1.jpg", name="image1.jpg", is_folder=False), + ] + + # Select first image, then shift-click third + model.select_index(0, shift=False, ctrl=False) + model.select_index(2, shift=True, ctrl=False) + + selected = model.get_selected_paths() + + # Should have 2 images selected (folder skipped) + assert len(selected) == 2 + + names = [p.name for p in selected] + assert "image0.jpg" in names + assert "image1.jpg" in names + assert "subfolder" not in names + + +class TestClearSelection: + """Tests for clearing selection.""" + + def test_clear_selection(self, model_with_images): + """Test that clear_selection removes all selections.""" + model_with_images.select_index(1, shift=False, ctrl=False) + model_with_images.select_index(3, shift=False, ctrl=True) + + assert len(model_with_images.get_selected_paths()) == 2 + + model_with_images.clear_selection() + + assert len(model_with_images.get_selected_paths()) == 0 + + def test_clear_selection_empty(self, model_with_images): + """Test that clear_selection on empty selection is safe.""" + assert len(model_with_images.get_selected_paths()) == 0 + + # Should not error + model_with_images.clear_selection() + + assert len(model_with_images.get_selected_paths()) == 0 + + +class TestSelectionDataChanged: + """Tests for dataChanged signal emission on selection changes.""" + + def test_select_emits_data_changed(self, model_with_images): + """Test that selection changes emit dataChanged signal.""" + # Track signal emission + signal_emitted = [] + model_with_images.dataChanged.connect(lambda *args: signal_emitted.append(args)) + + model_with_images.select_index(2, shift=False, ctrl=False) + + # Signal should have been emitted + assert len(signal_emitted) > 0 + + def test_clear_selection_emits_data_changed(self, model_with_images): + """Test that clear_selection emits dataChanged for selected rows.""" + model_with_images.select_index(2, shift=False, ctrl=False) + + # Track signal emission + signal_emitted = [] + model_with_images.dataChanged.connect(lambda *args: signal_emitted.append(args)) + + model_with_images.clear_selection() + + # Signal should have been emitted + assert len(signal_emitted) > 0 + + +class TestGetSelectedPaths: + """Tests for get_selected_paths method.""" + + def test_returns_paths_in_order(self, model_with_images): + """Test that get_selected_paths returns paths in index order.""" + model_with_images.select_index(3, shift=False, ctrl=False) + model_with_images.select_index(1, shift=False, ctrl=True) + model_with_images.select_index(4, shift=False, ctrl=True) + + selected = model_with_images.get_selected_paths() + + # Should be sorted by index + assert selected[0].name == "image1.jpg" + assert selected[1].name == "image3.jpg" + assert selected[2].name == "image4.jpg" + + def test_returns_only_files(self, temp_folder): + """Test that get_selected_paths only returns file paths.""" + model = ThumbnailModel( + base_directory=temp_folder, + current_directory=temp_folder, + get_metadata_callback=None, + ) + + model._entries = [ + ThumbnailEntry(path=temp_folder / "image.jpg", name="image.jpg", is_folder=False), + ] + + model.select_index(0, shift=False, ctrl=False) + selected = model.get_selected_paths() + + assert len(selected) == 1 + assert all(not p.is_dir() for p in selected if p.exists()) diff --git a/faststack/thumbnail_view/__init__.py b/faststack/thumbnail_view/__init__.py new file mode 100644 index 0000000..2ae8fd0 --- /dev/null +++ b/faststack/thumbnail_view/__init__.py @@ -0,0 +1,17 @@ +"""Thumbnail grid view components for FastStack.""" + +from .folder_stats import FolderStats, read_folder_stats +from .model import ThumbnailModel, ThumbnailEntry +from .prefetcher import ThumbnailPrefetcher, ThumbnailCache +from .provider import ThumbnailProvider, PathResolver + +__all__ = [ + "FolderStats", + "read_folder_stats", + "ThumbnailModel", + "ThumbnailEntry", + "ThumbnailPrefetcher", + "ThumbnailCache", + "ThumbnailProvider", + "PathResolver", +] diff --git a/faststack/thumbnail_view/folder_stats.py b/faststack/thumbnail_view/folder_stats.py new file mode 100644 index 0000000..bf013c5 --- /dev/null +++ b/faststack/thumbnail_view/folder_stats.py @@ -0,0 +1,113 @@ +"""Parse faststack.json for folder statistics display in thumbnail grid.""" + +import json +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Optional, Tuple + +log = logging.getLogger(__name__) + +SIDECAR_FILENAME = "faststack.json" + + +@dataclass +class FolderStats: + """Statistics parsed from a folder's faststack.json file.""" + total_images: int + stacked_count: int + uploaded_count: int + edited_count: int + + +# Cache by (folder_path, json_mtime_ns) to avoid re-parsing during scroll +# IMPORTANT: json_mtime_ns = stat(folder_path / "faststack.json").st_mtime_ns +# NOT the folder's mtime (folder mtime changes when any file is added/removed) +_stats_cache: Dict[Tuple[Path, int], Optional[FolderStats]] = {} + + +def read_folder_stats(folder_path: Path) -> Optional[FolderStats]: + """Parse faststack.json in folder. Stat the json file for mtime_ns. Tolerant to errors. + + Args: + folder_path: Path to the folder containing faststack.json + + Returns: + FolderStats if valid faststack.json exists, None otherwise. + Caches results by (folder_path, json_mtime_ns) to avoid re-parsing. + """ + json_path = folder_path / SIDECAR_FILENAME + + # Check if file exists + try: + stat_info = json_path.stat() + mtime_ns = stat_info.st_mtime_ns + except (OSError, FileNotFoundError): + # No faststack.json in this folder + return None + + # Check cache + cache_key = (folder_path.resolve(), mtime_ns) + if cache_key in _stats_cache: + return _stats_cache[cache_key] + + # Parse the JSON file + stats = _parse_faststack_json(json_path) + + # Cache the result (even if None) + _stats_cache[cache_key] = stats + + return stats + + +def _parse_faststack_json(json_path: Path) -> Optional[FolderStats]: + """Parse a faststack.json file and extract statistics. + + Tolerant to: + - Missing keys (uses defaults) + - Old formats (version < 2) + - Parse errors (returns None) + """ + try: + with open(json_path, "r", encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError, UnicodeDecodeError) as e: + log.debug("Failed to parse %s: %s", json_path, e) + return None + + # Handle different sidecar formats + entries = data.get("entries", {}) + if not isinstance(entries, dict): + log.debug("Invalid entries format in %s", json_path) + return None + + # Count statistics from entries + total_images = len(entries) + stacked_count = 0 + uploaded_count = 0 + edited_count = 0 + + for stem, meta in entries.items(): + if not isinstance(meta, dict): + continue + + if meta.get("stacked", False): + stacked_count += 1 + if meta.get("uploaded", False): + uploaded_count += 1 + if meta.get("edited", False): + edited_count += 1 + + return FolderStats( + total_images=total_images, + stacked_count=stacked_count, + uploaded_count=uploaded_count, + edited_count=edited_count, + ) + + +def clear_stats_cache(): + """Clear the folder stats cache.""" + global _stats_cache + _stats_cache.clear() + log.debug("Cleared folder stats cache") diff --git a/faststack/thumbnail_view/model.py b/faststack/thumbnail_view/model.py new file mode 100644 index 0000000..a77448e --- /dev/null +++ b/faststack/thumbnail_view/model.py @@ -0,0 +1,495 @@ +"""ThumbnailModel for QML GridView with file/folder entries.""" + +import hashlib +import logging +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional, Set, Callable + +from PySide6.QtCore import ( + QAbstractListModel, + QModelIndex, + Qt, + Signal, + Slot, +) + +from faststack.io.indexer import find_images +from faststack.thumbnail_view.folder_stats import FolderStats, read_folder_stats + +log = logging.getLogger(__name__) + + +@dataclass +class ThumbnailEntry: + """A single entry in the thumbnail grid (file or folder).""" + path: Path + name: str + is_folder: bool + is_stacked: bool = False + is_uploaded: bool = False + is_edited: bool = False + is_restacked: bool = False + folder_stats: Optional[FolderStats] = None + mtime_ns: int = 0 + thumb_rev: int = 0 # Bumped when thumbnail is ready, forces QML refresh + + +def _compute_path_hash(path: Path) -> str: + """Compute a stable hash of the path for cache key purposes.""" + return hashlib.md5(str(path.resolve()).encode("utf-8")).hexdigest()[:16] + + +class ThumbnailModel(QAbstractListModel): + """Qt model for thumbnail grid view. + + Provides entries for both folders and images, with support for: + - Selection state for batch operations + - Thumbnail revision tracking for QML refresh + - Parent folder navigation (..) + """ + + # Custom roles for QML + FilePathRole = Qt.ItemDataRole.UserRole + 1 + FileNameRole = Qt.ItemDataRole.UserRole + 2 + IsFolderRole = Qt.ItemDataRole.UserRole + 3 + IsStackedRole = Qt.ItemDataRole.UserRole + 4 + IsUploadedRole = Qt.ItemDataRole.UserRole + 5 + IsEditedRole = Qt.ItemDataRole.UserRole + 6 + ThumbnailSourceRole = Qt.ItemDataRole.UserRole + 7 + FolderStatsRole = Qt.ItemDataRole.UserRole + 8 + IsSelectedRole = Qt.ItemDataRole.UserRole + 9 + ThumbRevRole = Qt.ItemDataRole.UserRole + 10 + PathHashRole = Qt.ItemDataRole.UserRole + 11 + MtimeNsRole = Qt.ItemDataRole.UserRole + 12 + IsParentFolderRole = Qt.ItemDataRole.UserRole + 13 + IsRestackedRole = Qt.ItemDataRole.UserRole + 14 + IsInBatchRole = Qt.ItemDataRole.UserRole + 15 + IsCurrentRole = Qt.ItemDataRole.UserRole + 16 + + # Signal emitted when a thumbnail is ready (id = "{size}/{path_hash}/{mtime_ns}") + thumbnailReady = Signal(str) + # Signal emitted when selection changes (for UIState to forward to QML) + selectionChanged = Signal() + + def __init__( + self, + base_directory: Path, + current_directory: Path, + get_metadata_callback: Optional[Callable[[str], dict]] = None, + get_batch_indices_callback: Optional[Callable[[], Set[int]]] = None, + get_current_index_callback: Optional[Callable[[], int]] = None, + thumbnail_size: int = 200, + parent=None, + ): + super().__init__(parent) + self._base_directory = base_directory.resolve() + self._current_directory = current_directory.resolve() + self._get_metadata = get_metadata_callback + self._get_batch_indices = get_batch_indices_callback + self._get_current_index = get_current_index_callback + self._thumbnail_size = thumbnail_size + self._entries: List[ThumbnailEntry] = [] + self._selected_indices: Set[int] = set() + self._last_selected_index: Optional[int] = None + + # Mapping from thumbnail_id (without query params) to row index + # id format: "{size}/{path_hash}/{mtime_ns}" + self._id_to_row: Dict[str, int] = {} + + # Connect our own signal to handle thumbnail ready events + self.thumbnailReady.connect(self._on_thumbnail_ready) + + @property + def current_directory(self) -> Path: + """Current directory being displayed.""" + return self._current_directory + + @property + def base_directory(self) -> Path: + """Base directory (can't navigate above this).""" + return self._base_directory + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + if parent.isValid(): + return 0 + return len(self._entries) + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): + if not index.isValid() or index.row() >= len(self._entries): + return None + + entry = self._entries[index.row()] + row = index.row() + + if role == Qt.ItemDataRole.DisplayRole or role == self.FileNameRole: + return entry.name + elif role == self.FilePathRole: + return str(entry.path) + elif role == self.IsFolderRole: + return entry.is_folder + elif role == self.IsStackedRole: + return entry.is_stacked + elif role == self.IsUploadedRole: + return entry.is_uploaded + elif role == self.IsEditedRole: + return entry.is_edited + elif role == self.ThumbnailSourceRole: + return self._get_thumbnail_source(entry) + elif role == self.FolderStatsRole: + if entry.folder_stats: + return { + "total_images": entry.folder_stats.total_images, + "stacked_count": entry.folder_stats.stacked_count, + "uploaded_count": entry.folder_stats.uploaded_count, + "edited_count": entry.folder_stats.edited_count, + } + return None + elif role == self.IsSelectedRole: + return row in self._selected_indices + elif role == self.ThumbRevRole: + return entry.thumb_rev + elif role == self.PathHashRole: + return _compute_path_hash(entry.path) + elif role == self.MtimeNsRole: + return entry.mtime_ns + elif role == self.IsParentFolderRole: + return entry.name == ".." and entry.is_folder + elif role == self.IsRestackedRole: + return entry.is_restacked + elif role == self.IsInBatchRole: + # Check if this row's corresponding loupe index is in any batch + if self._get_batch_indices and not entry.is_folder: + batch_indices = self._get_batch_indices() + # Find the loupe index for this entry + loupe_idx = self._get_loupe_index_for_entry(entry) + return loupe_idx is not None and loupe_idx in batch_indices + return False + elif role == self.IsCurrentRole: + # Check if this entry is the current image in loupe view + if self._get_current_index and not entry.is_folder: + current_idx = self._get_current_index() + loupe_idx = self._get_loupe_index_for_entry(entry) + return loupe_idx is not None and loupe_idx == current_idx + return False + + return None + + def _get_loupe_index_for_entry(self, entry: ThumbnailEntry) -> Optional[int]: + """Get the loupe view index for a thumbnail entry.""" + # This requires access to the app controller's _path_to_index + # We'll use the parent (AppController) to look this up + parent = self.parent() + if parent and hasattr(parent, '_path_to_index'): + return parent._path_to_index.get(entry.path.resolve()) + return None + + def roleNames(self) -> Dict[int, bytes]: + return { + Qt.ItemDataRole.DisplayRole: b"display", + self.FilePathRole: b"filePath", + self.FileNameRole: b"fileName", + self.IsFolderRole: b"isFolder", + self.IsStackedRole: b"isStacked", + self.IsUploadedRole: b"isUploaded", + self.IsEditedRole: b"isEdited", + self.ThumbnailSourceRole: b"thumbnailSource", + self.FolderStatsRole: b"folderStats", + self.IsSelectedRole: b"isSelected", + self.ThumbRevRole: b"thumbRev", + self.PathHashRole: b"pathHash", + self.MtimeNsRole: b"mtimeNs", + self.IsParentFolderRole: b"isParentFolder", + self.IsRestackedRole: b"isRestacked", + self.IsInBatchRole: b"isInBatch", + self.IsCurrentRole: b"isCurrent", + } + + def _get_thumbnail_source(self, entry: ThumbnailEntry) -> str: + """Build thumbnail URL for QML Image source. + + Format: image://thumbnail/{size}/{path_hash}/{mtime_ns}?r={rev} + Folders use: image://thumbnail/folder/{path_hash}/{mtime_ns}?r={rev} + """ + path_hash = _compute_path_hash(entry.path) + mtime_ns = entry.mtime_ns + rev = entry.thumb_rev + + if entry.is_folder: + return f"image://thumbnail/folder/{path_hash}/{mtime_ns}?r={rev}" + else: + return f"image://thumbnail/{self._thumbnail_size}/{path_hash}/{mtime_ns}?r={rev}" + + def refresh(self, filter_string: str = ""): + """Refresh the model by rescanning the current directory. + + Args: + filter_string: Optional filter to apply to filenames (case-insensitive) + """ + self.beginResetModel() + + self._entries.clear() + self._id_to_row.clear() + self._selected_indices.clear() + self._last_selected_index = None + + # Add parent folder entry if not at base + if self._current_directory != self._base_directory: + parent_path = self._current_directory.parent + self._entries.append(ThumbnailEntry( + path=parent_path, + name="..", + is_folder=True, + mtime_ns=0, + )) + + # Scan for folders + folders: List[ThumbnailEntry] = [] + try: + for entry in os.scandir(self._current_directory): + if entry.is_dir() and not entry.name.startswith("."): + folder_path = Path(entry.path) + try: + stat_info = entry.stat() + mtime_ns = stat_info.st_mtime_ns + except OSError: + mtime_ns = 0 + + folder_stats = read_folder_stats(folder_path) + + folders.append(ThumbnailEntry( + path=folder_path, + name=entry.name, + is_folder=True, + folder_stats=folder_stats, + mtime_ns=mtime_ns, + )) + except OSError as e: + log.warning("Error scanning directory %s: %s", self._current_directory, e) + + # Sort folders alphabetically + folders.sort(key=lambda e: e.name.lower()) + self._entries.extend(folders) + + # Get images using existing indexer (respects filter rules) + images = find_images(self._current_directory) + + # Apply filter if specified + if filter_string: + needle = filter_string.lower() + images = [img for img in images if needle in img.path.stem.lower()] + + # Convert ImageFile to ThumbnailEntry + for img in images: + try: + stat_info = img.path.stat() + mtime_ns = stat_info.st_mtime_ns + except OSError: + mtime_ns = int(img.timestamp * 1e9) if img.timestamp else 0 + + # Get metadata if callback provided + is_stacked = False + is_uploaded = False + is_edited = False + is_restacked = False + + if self._get_metadata: + try: + meta = self._get_metadata(img.path.stem) + is_stacked = meta.get("stacked", False) + is_uploaded = meta.get("uploaded", False) + is_edited = meta.get("edited", False) + is_restacked = meta.get("restacked", False) + except Exception: + pass + + self._entries.append(ThumbnailEntry( + path=img.path, + name=img.path.name, + is_folder=False, + is_stacked=is_stacked, + is_uploaded=is_uploaded, + is_edited=is_edited, + is_restacked=is_restacked, + mtime_ns=mtime_ns, + )) + + # Build id_to_row mapping + self._rebuild_id_mapping() + + self.endResetModel() + # Selection was cleared during refresh + self.selectionChanged.emit() + log.info("ThumbnailModel refreshed: %d entries (%d folders, %d images)", + len(self._entries), + sum(1 for e in self._entries if e.is_folder), + sum(1 for e in self._entries if not e.is_folder)) + + def _rebuild_id_mapping(self): + """Rebuild the id_to_row mapping for all entries.""" + self._id_to_row.clear() + for row, entry in enumerate(self._entries): + if entry.name == "..": + continue # Don't map parent folder + thumbnail_id = self._make_thumbnail_id(entry) + self._id_to_row[thumbnail_id] = row + + def _make_thumbnail_id(self, entry: ThumbnailEntry) -> str: + """Create thumbnail ID without query params.""" + path_hash = _compute_path_hash(entry.path) + if entry.is_folder: + return f"folder/{path_hash}/{entry.mtime_ns}" + else: + return f"{self._thumbnail_size}/{path_hash}/{entry.mtime_ns}" + + @Slot(str) + def _on_thumbnail_ready(self, thumbnail_id: str): + """Handle thumbnail ready signal - bump revision and emit dataChanged.""" + if thumbnail_id not in self._id_to_row: + return + + row = self._id_to_row[thumbnail_id] + if row < 0 or row >= len(self._entries): + return + + # Bump the revision + self._entries[row].thumb_rev += 1 + + # Emit dataChanged for thumbnailSource role + idx = self.index(row, 0) + self.dataChanged.emit(idx, idx, [self.ThumbnailSourceRole, self.ThumbRevRole]) + + def set_directories(self, base_directory: Path, current_directory: Path): + """Set both base and current directories (for open folder). + + This resets the model to a new root directory. + + Args: + base_directory: The new base/root directory. + current_directory: The new current directory (usually same as base). + """ + self._base_directory = base_directory.resolve() + self._current_directory = current_directory.resolve() + self._selected_indices.clear() + self._last_selected_index = None + # Don't call refresh() here - caller should do it after updating other state + + def navigate_to(self, path: Path): + """Navigate to a different directory. + + Args: + path: Directory to navigate to. Must be within base_directory. + """ + resolved = path.resolve() + + # Security check: don't navigate outside base directory + try: + resolved.relative_to(self._base_directory) + except ValueError: + log.warning("Attempted to navigate outside base directory: %s", resolved) + return + + if not resolved.is_dir(): + log.warning("Cannot navigate to non-directory: %s", resolved) + return + + self._current_directory = resolved + self._selected_indices.clear() + self._last_selected_index = None + self.refresh() + + # Selection methods + + def select_index(self, idx: int, shift: bool = False, ctrl: bool = False): + """Handle selection at index with modifier keys. + + Args: + idx: Index to select + shift: Shift key held (range select) + ctrl: Ctrl key held (toggle individual) + """ + if idx < 0 or idx >= len(self._entries): + return + + # Don't allow selecting folders or parent + entry = self._entries[idx] + if entry.is_folder: + return + + old_selection = self._selected_indices.copy() + + if shift and self._last_selected_index is not None: + # Range selection + start = min(self._last_selected_index, idx) + end = max(self._last_selected_index, idx) + for i in range(start, end + 1): + if not self._entries[i].is_folder: + self._selected_indices.add(i) + elif ctrl: + # Toggle individual + if idx in self._selected_indices: + self._selected_indices.discard(idx) + else: + self._selected_indices.add(idx) + self._last_selected_index = idx + else: + # Simple click - clear and select single + self._selected_indices.clear() + self._selected_indices.add(idx) + self._last_selected_index = idx + + # Emit dataChanged for affected rows + changed_rows = old_selection.symmetric_difference(self._selected_indices) + for row in changed_rows: + row_idx = self.index(row, 0) + self.dataChanged.emit(row_idx, row_idx, [self.IsSelectedRole]) + + # Notify if selection actually changed + if changed_rows: + self.selectionChanged.emit() + + def clear_selection(self): + """Clear all selections.""" + if not self._selected_indices: + return + + old_selection = self._selected_indices.copy() + self._selected_indices.clear() + self._last_selected_index = None + + for row in old_selection: + row_idx = self.index(row, 0) + self.dataChanged.emit(row_idx, row_idx, [self.IsSelectedRole]) + + self.selectionChanged.emit() + + def get_selected_paths(self) -> List[Path]: + """Get list of selected image paths.""" + return [ + self._entries[idx].path + for idx in sorted(self._selected_indices) + if idx < len(self._entries) and not self._entries[idx].is_folder + ] + + @property + def selected_count(self) -> int: + """Get count of selected items (efficient, no list copy).""" + return len(self._selected_indices) + + def get_entry(self, row: int) -> Optional[ThumbnailEntry]: + """Get entry at row.""" + if 0 <= row < len(self._entries): + return self._entries[row] + return None + + def find_image_index(self, path: Path) -> int: + """Find the row index of an image by path. + + Returns -1 if not found. + """ + resolved = path.resolve() + for i, entry in enumerate(self._entries): + if not entry.is_folder and entry.path.resolve() == resolved: + return i + return -1 diff --git a/faststack/thumbnail_view/prefetcher.py b/faststack/thumbnail_view/prefetcher.py new file mode 100644 index 0000000..882b6df --- /dev/null +++ b/faststack/thumbnail_view/prefetcher.py @@ -0,0 +1,326 @@ +"""Background thumbnail decode and prefetch for grid view.""" + +import hashlib +import logging +import os +from concurrent.futures import ThreadPoolExecutor, Future +from pathlib import Path +from threading import Lock +from typing import Dict, Optional, Set, Tuple, Callable + +import numpy as np +from PIL import Image + +from faststack.imaging.orientation import get_exif_orientation, apply_orientation_to_np + +log = logging.getLogger(__name__) + +# Try to import turbojpeg for faster JPEG decoding +try: + from turbojpeg import TurboJPEG, TJPF_RGB, TJSAMP_444 + _tj = TurboJPEG() + HAS_TURBOJPEG = True +except ImportError: + _tj = None + HAS_TURBOJPEG = False + log.debug("TurboJPEG not available, using PIL for thumbnail decoding") + + +def _compute_path_hash(path: Path) -> str: + """Compute a stable hash of the path for cache key purposes.""" + return hashlib.md5(str(path.resolve()).encode("utf-8")).hexdigest()[:16] + + +class ThumbnailPrefetcher: + """Background thumbnail decoder with ThreadPoolExecutor. + + Features: + - Non-blocking decode with callback on completion + - De-duplication of in-flight jobs + - EXIF orientation applied in exactly one place + - Cache key: (size, path_hash, mtime_ns) + """ + + def __init__( + self, + cache: "ByteLRUCache", + on_ready_callback: Optional[Callable[[str], None]] = None, + max_workers: int = None, + target_size: int = 200, + ): + """Initialize the prefetcher. + + Args: + cache: Cache to store decoded thumbnails + on_ready_callback: Called with thumbnail_id when decode completes + max_workers: Number of worker threads (default: min(4, cpu_count//2)) + target_size: Target thumbnail size in pixels + """ + if max_workers is None: + max_workers = min(4, max(1, (os.cpu_count() or 4) // 2)) + + self._cache = cache + self._on_ready = on_ready_callback + self._target_size = target_size + self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="thumb") + + # Track in-flight jobs to avoid duplicates + # Key: (size, path_hash, mtime_ns) + self._inflight: Set[Tuple[int, str, int]] = set() + self._inflight_lock = Lock() + + # Track futures for potential cancellation + self._futures: Dict[Tuple[int, str, int], Future] = {} + + log.info("ThumbnailPrefetcher initialized with %d workers, target size %dpx", + max_workers, target_size) + + def submit(self, path: Path, mtime_ns: int, size: int = None) -> bool: + """Submit a thumbnail decode job. + + Args: + path: Path to the image file + mtime_ns: File modification time in nanoseconds + size: Target size (default: self._target_size) + + Returns: + True if job was submitted, False if already in-flight or cached + """ + if size is None: + size = self._target_size + + path_hash = _compute_path_hash(path) + job_key = (size, path_hash, mtime_ns) + cache_key = f"{size}/{path_hash}/{mtime_ns}" + + # Check cache first + if self._cache.get(cache_key) is not None: + return False + + # Check/add to inflight set + with self._inflight_lock: + if job_key in self._inflight: + return False + self._inflight.add(job_key) + + # Submit decode job + try: + future = self._executor.submit( + self._decode_worker, + path, + path_hash, + mtime_ns, + size, + ) + future.add_done_callback(lambda f: self._on_decode_done(f, job_key, cache_key)) + + with self._inflight_lock: + self._futures[job_key] = future + + return True + except RuntimeError: + # Executor shutdown + with self._inflight_lock: + self._inflight.discard(job_key) + return False + + def prefetch_batch(self, entries: list, margin: int = 2): + """Prefetch thumbnails for a batch of entries. + + Args: + entries: List of ThumbnailEntry objects + margin: Extra entries to prefetch beyond visible range + """ + for entry in entries: + if not entry.is_folder: + self.submit(entry.path, entry.mtime_ns) + + def _decode_worker( + self, + path: Path, + path_hash: str, + mtime_ns: int, + size: int, + ) -> Optional[bytes]: + """Worker function to decode a thumbnail. + + Returns JPEG bytes or None on error. + """ + try: + # Read and decode + rgb_array = self._decode_image(path, size) + if rgb_array is None: + return None + + # Get EXIF orientation and apply (single point of orientation) + orientation = get_exif_orientation(path) + rgb_array = apply_orientation_to_np(rgb_array, orientation) + + # Encode to JPEG bytes for storage + pil_image = Image.fromarray(rgb_array, mode="RGB") + + # Use BytesIO to encode to JPEG + import io + buf = io.BytesIO() + pil_image.save(buf, format="JPEG", quality=85) + return buf.getvalue() + + except Exception as e: + log.debug("Failed to decode thumbnail for %s: %s", path, e) + return None + + def _decode_image(self, path: Path, target_size: int) -> Optional[np.ndarray]: + """Decode image to numpy array at target size. + + Uses TurboJPEG if available for faster decoding. + Returns RGB uint8 array. + """ + suffix = path.suffix.lower() + + # Try TurboJPEG for JPEG files + if HAS_TURBOJPEG and suffix in (".jpg", ".jpeg"): + try: + with open(path, "rb") as f: + jpeg_data = f.read() + + # Get dimensions first + width, height, _, _ = _tj.decode_header(jpeg_data) + + # Calculate scale factor for turbojpeg (powers of 2: 1, 2, 4, 8) + scale_factor = 1 + while (width // (scale_factor * 2) >= target_size and + height // (scale_factor * 2) >= target_size and + scale_factor < 8): + scale_factor *= 2 + + # Decode with scaling + scaling_factor = (1, scale_factor) + rgb = _tj.decode(jpeg_data, pixel_format=TJPF_RGB, scaling_factor=scaling_factor) + + # Further resize with PIL if needed + h, w = rgb.shape[:2] + if w > target_size or h > target_size: + pil_img = Image.fromarray(rgb) + pil_img.thumbnail((target_size, target_size), Image.Resampling.LANCZOS) + rgb = np.array(pil_img) + + return rgb + + except Exception as e: + log.debug("TurboJPEG decode failed for %s, falling back to PIL: %s", path, e) + + # Fallback to PIL + try: + with Image.open(path) as img: + # Convert to RGB if needed + if img.mode != "RGB": + img = img.convert("RGB") + + # Resize + img.thumbnail((target_size, target_size), Image.Resampling.LANCZOS) + return np.array(img) + + except Exception as e: + log.debug("PIL decode failed for %s: %s", path, e) + return None + + def _on_decode_done(self, future: Future, job_key: Tuple[int, str, int], cache_key: str): + """Callback when decode completes.""" + # Remove from inflight + with self._inflight_lock: + self._inflight.discard(job_key) + self._futures.pop(job_key, None) + + try: + jpeg_bytes = future.result() + if jpeg_bytes: + # Store in cache + self._cache.put(cache_key, jpeg_bytes) + + # Notify ready + if self._on_ready: + # Extract thumbnail_id from cache_key (same format) + self._on_ready(cache_key) + + except Exception as e: + log.debug("Thumbnail decode failed: %s", e) + + def cancel_all(self): + """Cancel all pending jobs.""" + with self._inflight_lock: + for future in self._futures.values(): + future.cancel() + self._futures.clear() + self._inflight.clear() + + def shutdown(self): + """Shutdown the executor.""" + self.cancel_all() + self._executor.shutdown(wait=False) + log.info("ThumbnailPrefetcher shutdown") + + +class ThumbnailCache: + """Simple byte-based LRU cache for thumbnails with dual capacity limit. + + Limits: + - max_bytes: Maximum total bytes + - max_items: Maximum number of items + """ + + def __init__(self, max_bytes: int = 256 * 1024 * 1024, max_items: int = 5000): + self._max_bytes = max_bytes + self._max_items = max_items + self._cache: Dict[str, bytes] = {} + self._order: list = [] # LRU order (oldest first) + self._current_bytes = 0 + self._lock = Lock() + + def get(self, key: str) -> Optional[bytes]: + """Get item from cache, returns None if not found.""" + with self._lock: + if key not in self._cache: + return None + # Move to end (most recently used) + self._order.remove(key) + self._order.append(key) + return self._cache[key] + + def put(self, key: str, value: bytes): + """Put item in cache, evicting if necessary.""" + with self._lock: + # If already present, remove old entry first + if key in self._cache: + old_value = self._cache[key] + self._current_bytes -= len(old_value) + self._order.remove(key) + + # Add new entry + self._cache[key] = value + self._order.append(key) + self._current_bytes += len(value) + + # Evict if over limits + while (self._current_bytes > self._max_bytes or + len(self._cache) > self._max_items) and self._order: + oldest = self._order.pop(0) + if oldest in self._cache: + self._current_bytes -= len(self._cache[oldest]) + del self._cache[oldest] + + def clear(self): + """Clear the cache.""" + with self._lock: + self._cache.clear() + self._order.clear() + self._current_bytes = 0 + + @property + def size(self) -> int: + """Current number of items.""" + return len(self._cache) + + @property + def bytes_used(self) -> int: + """Current bytes used.""" + return self._current_bytes diff --git a/faststack/thumbnail_view/provider.py b/faststack/thumbnail_view/provider.py new file mode 100644 index 0000000..5dc18a0 --- /dev/null +++ b/faststack/thumbnail_view/provider.py @@ -0,0 +1,219 @@ +"""QML Image Provider for thumbnail grid view.""" + +import io +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +from PySide6.QtCore import QSize +from PySide6.QtGui import QImage, QPixmap, QColor +from PySide6.QtQuick import QQuickImageProvider + +if TYPE_CHECKING: + from faststack.thumbnail_view.prefetcher import ThumbnailPrefetcher, ThumbnailCache + +log = logging.getLogger(__name__) + +# Placeholder colors +PLACEHOLDER_COLOR = QColor(60, 60, 60) # Neutral gray for loading +FOLDER_COLOR = QColor(80, 80, 80) # Slightly different for folders +ERROR_COLOR = QColor(80, 40, 40) # Dark red for errors + + +class ThumbnailProvider(QQuickImageProvider): + """QML Image Provider for thumbnails. + + Non-blocking O(1) implementation: + - Returns cached pixmap if available + - Returns placeholder immediately if not cached + - Schedules decode via prefetcher (does NOT decode inline) + + URL format: + - Files: image://thumbnail/{size}/{path_hash}/{mtime_ns}?r={rev} + - Folders: image://thumbnail/folder/{path_hash}/{mtime_ns}?r={rev} + """ + + def __init__( + self, + cache: "ThumbnailCache", + prefetcher: "ThumbnailPrefetcher", + path_resolver: callable = None, + default_size: int = 200, + ): + """Initialize the provider. + + Args: + cache: Thumbnail cache to read from + prefetcher: Prefetcher to schedule decodes + path_resolver: Function to resolve path_hash to actual Path + default_size: Default thumbnail size + """ + super().__init__(QQuickImageProvider.ImageType.Pixmap) + self._cache = cache + self._prefetcher = prefetcher + self._path_resolver = path_resolver + self._default_size = default_size + + # Pre-create placeholder pixmaps + self._placeholder = self._create_placeholder(default_size, PLACEHOLDER_COLOR) + self._folder_placeholder = self._create_folder_placeholder(default_size) + self._error_placeholder = self._create_placeholder(default_size, ERROR_COLOR) + + log.debug("ThumbnailProvider initialized with default size %d", default_size) + + def _create_placeholder(self, size: int, color: QColor) -> QPixmap: + """Create a solid color placeholder pixmap.""" + pixmap = QPixmap(size, size) + pixmap.fill(color) + return pixmap + + def _create_folder_placeholder(self, size: int) -> QPixmap: + """Create a folder icon placeholder.""" + from PySide6.QtGui import QPainter, QPen, QBrush + + pixmap = QPixmap(size, size) + pixmap.fill(FOLDER_COLOR) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Draw a simple folder shape + pen = QPen(QColor(150, 150, 150)) + pen.setWidth(2) + painter.setPen(pen) + painter.setBrush(QBrush(QColor(100, 100, 100))) + + # Folder body + margin = size // 6 + tab_width = size // 3 + tab_height = size // 8 + + # Tab at top + painter.drawRect( + margin, + margin + tab_height, + size - 2 * margin, + size - 2 * margin - tab_height + ) + + # Tab extension + painter.fillRect( + margin, + margin, + tab_width, + tab_height + 2, + QColor(100, 100, 100) + ) + + painter.end() + return pixmap + + def requestPixmap(self, id_str: str, size: QSize, requestedSize: QSize) -> QPixmap: + """Request a pixmap for the given ID. + + This method is O(1) - returns immediately with cached data or placeholder. + + Args: + id_str: URL path after "image://thumbnail/" + size: Output size reference (set by us) + requestedSize: Requested size from QML + + Returns: + QPixmap of the thumbnail or placeholder + """ + # Parse the ID + # Format: {size}/{path_hash}/{mtime_ns}?r={rev} + # Or: folder/{path_hash}/{mtime_ns}?r={rev} + + # Strip query params + id_clean = id_str.split("?")[0] + parts = id_clean.split("/") + + if len(parts) < 3: + log.debug("Invalid thumbnail ID format: %s", id_str) + return self._error_placeholder + + # Determine if folder + if parts[0] == "folder": + # Folder thumbnail + path_hash = parts[1] + try: + mtime_ns = int(parts[2]) + except ValueError: + return self._error_placeholder + + # Folders just get folder icon + return self._folder_placeholder + + # File thumbnail + try: + thumb_size = int(parts[0]) + path_hash = parts[1] + mtime_ns = int(parts[2]) + except (ValueError, IndexError): + log.debug("Invalid thumbnail ID: %s", id_str) + return self._error_placeholder + + # Build cache key (same as id_clean) + cache_key = f"{thumb_size}/{path_hash}/{mtime_ns}" + + # Check cache (O(1) lookup) + cached_bytes = self._cache.get(cache_key) + if cached_bytes: + # Decode JPEG bytes to pixmap + pixmap = self._bytes_to_pixmap(cached_bytes) + if pixmap and not pixmap.isNull(): + return pixmap + + # Not in cache - schedule decode if we can resolve the path + if self._path_resolver: + path = self._path_resolver(path_hash) + if path: + self._prefetcher.submit(path, mtime_ns, thumb_size) + + # Return placeholder immediately (non-blocking) + return self._placeholder + + def _bytes_to_pixmap(self, jpeg_bytes: bytes) -> Optional[QPixmap]: + """Convert JPEG bytes to QPixmap.""" + try: + qimage = QImage() + if qimage.loadFromData(jpeg_bytes, "JPEG"): + return QPixmap.fromImage(qimage) + except Exception as e: + log.debug("Failed to convert bytes to pixmap: %s", e) + return None + + +class PathResolver: + """Resolves path hashes back to actual paths. + + Maintains a mapping from hash -> path for the current directory. + """ + + def __init__(self): + self._hash_to_path: dict = {} + + def register(self, path: Path, path_hash: str): + """Register a path with its hash.""" + self._hash_to_path[path_hash] = path + + def resolve(self, path_hash: str) -> Optional[Path]: + """Resolve a hash to its path.""" + return self._hash_to_path.get(path_hash) + + def clear(self): + """Clear all registered paths.""" + self._hash_to_path.clear() + + def update_from_model(self, model: "ThumbnailModel"): + """Update registrations from a ThumbnailModel.""" + import hashlib + self.clear() + for i in range(model.rowCount()): + entry = model.get_entry(i) + if entry and not entry.is_folder: + path_hash = hashlib.md5( + str(entry.path.resolve()).encode("utf-8") + ).hexdigest()[:16] + self._hash_to_path[path_hash] = entry.path diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index 7e92236..8af029e 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -1141,4 +1141,102 @@ def debugMode(self, value: bool): # --- RAW / Editor Source Logic --- + # --- Grid View Properties --- + + # Signals for grid view + isGridViewActiveChanged = Signal(bool) + gridDirectoryChanged = Signal(str) + gridSelectedCountChanged = Signal() # No args - QML property notify pattern + + @Property(bool, notify=isGridViewActiveChanged) + def isGridViewActive(self) -> bool: + """Returns True if grid view is active, False for loupe view.""" + return getattr(self.app_controller, '_is_grid_view_active', False) + + @isGridViewActive.setter + def isGridViewActive(self, value: bool): + # Use controller method to ensure side effects (model refresh, resolver update) are applied + if hasattr(self.app_controller, '_set_grid_view_active'): + self.app_controller._set_grid_view_active(value) + + @Property(str, notify=gridDirectoryChanged) + def gridDirectory(self) -> str: + """Returns the current directory shown in grid view.""" + if hasattr(self.app_controller, '_thumbnail_model') and self.app_controller._thumbnail_model: + return str(self.app_controller._thumbnail_model.current_directory) + return str(self.app_controller.image_dir) + + @Property(int, notify=gridSelectedCountChanged) + def gridSelectedCount(self) -> int: + """Returns count of selected items in grid view (efficient, no list copy).""" + if hasattr(self.app_controller, '_thumbnail_model') and self.app_controller._thumbnail_model: + return self.app_controller._thumbnail_model.selected_count + return 0 + + @Slot() + def toggleGridView(self): + """Toggle between grid view and loupe view.""" + if hasattr(self.app_controller, 'toggle_grid_view'): + self.app_controller.toggle_grid_view() + + @Slot(int) + def gridOpenIndex(self, index: int): + """Open an image from grid view in loupe view.""" + if hasattr(self.app_controller, 'grid_open_index'): + self.app_controller.grid_open_index(index) + + @Slot(str) + def gridNavigateTo(self, path: str): + """Navigate to a folder in grid view.""" + if hasattr(self.app_controller, 'grid_navigate_to'): + self.app_controller.grid_navigate_to(path) + + @Slot() + def gridClearSelection(self): + """Clear all selections in grid view.""" + if hasattr(self.app_controller, '_thumbnail_model') and self.app_controller._thumbnail_model: + self.app_controller._thumbnail_model.clear_selection() + + @Slot(int, bool, bool) + def gridSelectIndex(self, index: int, shift: bool, ctrl: bool): + """Handle selection at index with modifier keys.""" + if hasattr(self.app_controller, '_thumbnail_model') and self.app_controller._thumbnail_model: + self.app_controller._thumbnail_model.select_index(index, shift, ctrl) + + @Slot(result='QVariantList') + def gridGetSelectedPaths(self) -> list: + """Get list of selected image paths in grid view.""" + if hasattr(self.app_controller, '_thumbnail_model') and self.app_controller._thumbnail_model: + return [str(p) for p in self.app_controller._thumbnail_model.get_selected_paths()] + return [] + + @Slot() + def gridRefresh(self): + """Refresh the grid view.""" + if hasattr(self.app_controller, '_thumbnail_model') and self.app_controller._thumbnail_model: + self.app_controller._thumbnail_model.refresh() + # Also update path resolver + if hasattr(self.app_controller, '_path_resolver'): + self.app_controller._path_resolver.update_from_model(self.app_controller._thumbnail_model) + + @Slot(int, int) + def gridPrefetchRange(self, startIndex: int, endIndex: int): + """Prefetch thumbnails for the given index range.""" + if not hasattr(self.app_controller, '_thumbnail_model') or not self.app_controller._thumbnail_model: + return + if not hasattr(self.app_controller, '_thumbnail_prefetcher') or not self.app_controller._thumbnail_prefetcher: + return + + model = self.app_controller._thumbnail_model + prefetcher = self.app_controller._thumbnail_prefetcher + + # Clamp indices + startIndex = max(0, startIndex) + endIndex = min(model.rowCount() - 1, endIndex) + + # Submit prefetch jobs for visible range + for i in range(startIndex, endIndex + 1): + entry = model.get_entry(i) + if entry and not entry.is_folder: + prefetcher.submit(entry.path, entry.mtime_ns) diff --git a/pyproject.toml b/pyproject.toml index abc614b..f615b6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "faststack" -version = "1.5.2" +version = "1.5.3" authors = [ { name="Alan Rockefeller"}, ]