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"},
]