diff --git a/faststack/app.py b/faststack/app.py index 027432e..7d81ce8 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -1055,14 +1055,12 @@ def grid_open_index(self, index: int): # Image might be in a different directory - don't switch view return - self.current_index = loupe_index - - # Switch to loupe view + # Switch to loupe view first (avoids transient work while still in grid) self._set_grid_view_active(False) - # Sync UI and trigger image load - self.sync_ui_state() - self.prefetcher.update_prefetch(self.current_index) + # Then set index with navigation=True for proper state reset and prefetch + self._set_current_index(loupe_index, is_navigation=True) + log.info("Opened image from grid: %s", entry.path) def _on_thumbnail_ready(self, thumbnail_id: str): @@ -1091,7 +1089,8 @@ def _get_metadata_dict(self, stem: str) -> dict: "edited": getattr(meta, "edited", False), "restacked": getattr(meta, "restacked", False), } - except Exception: + except Exception as e: # Broad catch for UI plumbing - don't crash grid view + log.debug("Failed to get metadata for %s: %s", stem, e) return {"stacked": False, "uploaded": False, "edited": False, "restacked": False} def _invalidate_batch_cache(self): diff --git a/faststack/imaging/prefetch.py b/faststack/imaging/prefetch.py index 8070642..98b14a3 100644 --- a/faststack/imaging/prefetch.py +++ b/faststack/imaging/prefetch.py @@ -447,18 +447,31 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, # ICC conversion failed, fall back to standard decode log.warning("ICC profile conversion failed for %s: %s, falling back to standard decode", image_file.path, e) t_before_fallback_read = time.perf_counter() - with open(image_file.path, "rb") as f: - with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: - # Pass mmap directly - no copy! - if use_resized and should_resize: - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) - else: - # Quality mode or Full Res: decode full image then resize with high quality - buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) - if buffer is not None and should_resize: - img = PILImage.fromarray(buffer) + + if is_jpeg: + # JPEG-specific fast path with mmap + TurboJPEG + with open(image_file.path, "rb") as f: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + if use_resized and should_resize: + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + else: + buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) + if buffer is not None and should_resize: + img = PILImage.fromarray(buffer) + img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) + buffer = np.array(img) + else: + # Generic Pillow fallback for non-JPEGs + try: + with PILImage.open(image_file.path) as img: + img = img.convert("RGB") + if should_resize: img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) - buffer = np.array(img) + buffer = np.array(img) + except Exception as e: + log.warning("Pillow fallback failed for %s: %s", image_file.path, e) + return None + t_after_fallback_read = time.perf_counter() if buffer is None: return None @@ -487,18 +500,31 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, # Fall back to standard decode if ICC profile not available log.warning("ICC mode selected but no monitor profile available, using standard decode") t_before_read = time.perf_counter() - with open(image_file.path, "rb") as f: - with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: - # Pass mmap directly - no copy! - if use_resized and should_resize: - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) - else: - # Quality mode or Full Res: decode full image then resize with high quality - buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) - if buffer is not None and should_resize: - img = PILImage.fromarray(buffer) + + if is_jpeg: + # JPEG-specific fast path with mmap + TurboJPEG + with open(image_file.path, "rb") as f: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + if use_resized and should_resize: + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + else: + buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) + if buffer is not None and should_resize: + img = PILImage.fromarray(buffer) + img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) + buffer = np.array(img) + else: + # Generic Pillow fallback for non-JPEGs + try: + with PILImage.open(image_file.path) as img: + img = img.convert("RGB") + if should_resize: img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) - buffer = np.array(img) + buffer = np.array(img) + except Exception as e: + log.warning("Pillow fallback failed for %s: %s", image_file.path, e) + return None + t_after_read = time.perf_counter() if buffer is None: return None diff --git a/faststack/qml/ExifDialog.qml b/faststack/qml/ExifDialog.qml index 7b5424d..7f8dd27 100644 --- a/faststack/qml/ExifDialog.qml +++ b/faststack/qml/ExifDialog.qml @@ -68,7 +68,7 @@ Dialog { wrapMode: Text.Wrap color: exifDialog.textColor background: null - font.family: "Consolas, monospace" + font.family: "Monospace" font.pixelSize: 14 } } diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 97c06c4..40f39dd 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -741,6 +741,14 @@ ApplicationWindow { active: true // Keep loaded to preserve state during view toggle visible: uiState && uiState.isGridViewActive focus: uiState && uiState.isGridViewActive + + // Bind theme property to loaded item + Binding { + target: gridViewLoader.item + property: "isDarkTheme" + value: root.isDarkTheme + when: gridViewLoader.item + } } } } @@ -921,7 +929,7 @@ ApplicationWindow { Button { text: "Clear" visible: uiState ? uiState.gridSelectedCount > 0 : false - onClicked: uiState.gridClearSelection() + onClicked: { if (uiState) uiState.gridClearSelection() } implicitWidth: 60 implicitHeight: 28 } @@ -929,7 +937,7 @@ ApplicationWindow { // Refresh button Button { text: "Refresh" - onClicked: uiState.gridRefresh() + onClicked: { if (uiState) uiState.gridRefresh() } implicitWidth: 70 implicitHeight: 28 } @@ -937,7 +945,7 @@ ApplicationWindow { // Single Image View button Button { text: "Single Image" - onClicked: uiState.toggleGridView() + onClicked: { if (uiState) uiState.toggleGridView() } implicitWidth: 90 implicitHeight: 28 } diff --git a/faststack/qml/ThumbnailGridView.qml b/faststack/qml/ThumbnailGridView.qml index ec4ff2d..a1d804d 100644 --- a/faststack/qml/ThumbnailGridView.qml +++ b/faststack/qml/ThumbnailGridView.qml @@ -7,19 +7,15 @@ Item { id: gridViewRoot anchors.fill: parent + // Theme property (bound by parent) + property bool isDarkTheme: false + // 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() : [] - } - } + // Selection count for keyboard handler (use gridSelectedCount for efficiency) + property int selectedCount: uiState ? uiState.gridSelectedCount : 0 // Grid view GridView { @@ -37,6 +33,9 @@ Item { width: thumbnailGrid.cellWidth - 10 height: thumbnailGrid.cellHeight - 10 + // Theme binding from parent + isDarkTheme: gridViewRoot.isDarkTheme + // Model role bindings - use attached property 'index' directly // Model roles become context properties in delegate tileIndex: index @@ -121,7 +120,7 @@ Item { anchors.centerIn: parent visible: thumbnailGrid.count === 0 text: "No images in this folder" - color: root.isDarkTheme ? "#888888" : "#666666" + color: gridViewRoot.isDarkTheme ? "#888888" : "#666666" font.pixelSize: 16 } } @@ -130,7 +129,8 @@ Item { Keys.onPressed: function(event) { if (event.key === Qt.Key_Escape) { // Clear selection or switch to loupe - if (gridViewRoot.selectedPaths.length > 0) { + if (!uiState) return + if (gridViewRoot.selectedCount > 0) { uiState.gridClearSelection() } else { uiState.toggleGridView() @@ -138,6 +138,7 @@ Item { event.accepted = true } else if (event.key === Qt.Key_Backspace) { // Navigate to parent + if (!uiState) return var model = thumbnailModel if (model && model.rowCount() > 0) { // Check if first item is parent folder diff --git a/faststack/qml/ThumbnailTile.qml b/faststack/qml/ThumbnailTile.qml index 2575e8f..7c13898 100644 --- a/faststack/qml/ThumbnailTile.qml +++ b/faststack/qml/ThumbnailTile.qml @@ -22,15 +22,18 @@ Item { property bool tileIsSelected: false property bool tileIsParentFolder: false + // Theme property (bound by parent) + property bool isDarkTheme: false + // Configuration property int tileSize: 180 property int thumbnailSize: 160 property int textHeight: 24 - property color textColor: root.isDarkTheme ? "white" : "black" + property color textColor: tile.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" + property color hoverColor: tile.isDarkTheme ? "#404040" : "#e0e0e0" + property color backgroundColor: tile.isDarkTheme ? "#2d2d2d" : "#fafafa" width: tileSize height: tileSize + textHeight @@ -97,7 +100,7 @@ Item { Rectangle { anchors.fill: parent visible: thumbnailImage.status === Image.Loading - color: root.isDarkTheme ? "#3c3c3c" : "#e0e0e0" + color: tile.isDarkTheme ? "#3c3c3c" : "#e0e0e0" BusyIndicator { anchors.centerIn: parent diff --git a/faststack/tests/thumbnail_view/test_folder_stats.py b/faststack/tests/thumbnail_view/test_folder_stats.py index 033f6a5..59bdc0b 100644 --- a/faststack/tests/thumbnail_view/test_folder_stats.py +++ b/faststack/tests/thumbnail_view/test_folder_stats.py @@ -138,11 +138,13 @@ def test_cache_invalidation_on_mtime_change(self, temp_folder): stats1 = read_folder_stats(temp_folder) assert stats1.stacked_count == 1 - # Modify file - import time - time.sleep(0.01) # Ensure different mtime + # Modify file with explicit mtime change + import os data["entries"]["IMG_002"] = {"stacked": True} json_path.write_text(json.dumps(data)) + # Set mtime to future to ensure cache invalidation + new_time = json_path.stat().st_mtime + 1 + os.utime(json_path, (new_time, new_time)) # Second read should get new data stats2 = read_folder_stats(temp_folder) diff --git a/faststack/tests/thumbnail_view/test_model.py b/faststack/tests/thumbnail_view/test_model.py index 7cd5d01..3e189da 100644 --- a/faststack/tests/thumbnail_view/test_model.py +++ b/faststack/tests/thumbnail_view/test_model.py @@ -159,7 +159,7 @@ def test_parent_folder_at_subdirectory(self, mock_find_images, temp_folder): 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): + def test_no_parent_folder_at_base(self, mock_find_images, model): """Test that no parent folder entry when at base directory.""" from faststack.models import ImageFile @@ -256,7 +256,7 @@ def test_clear_selection(self, mock_find_images, model, temp_folder): 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): + def test_cannot_select_folders(self, mock_find_images, model): """Test that folders cannot be selected.""" from faststack.models import ImageFile diff --git a/faststack/tests/thumbnail_view/test_prefetcher.py b/faststack/tests/thumbnail_view/test_prefetcher.py index 3d1b45b..0f01fe5 100644 --- a/faststack/tests/thumbnail_view/test_prefetcher.py +++ b/faststack/tests/thumbnail_view/test_prefetcher.py @@ -167,7 +167,7 @@ def test_submit_skips_if_cached(self, prefetcher, test_image, cache): result = prefetcher.submit(test_image, mtime_ns) assert result is False - def test_submit_deduplicates_inflight(self, prefetcher, test_image, cache): + def test_submit_deduplicates_inflight(self, prefetcher, test_image): """Test that duplicate in-flight jobs are skipped.""" mtime_ns = test_image.stat().st_mtime_ns diff --git a/faststack/tests/thumbnail_view/test_selection.py b/faststack/tests/thumbnail_view/test_selection.py index fc24c5b..b7eea64 100644 --- a/faststack/tests/thumbnail_view/test_selection.py +++ b/faststack/tests/thumbnail_view/test_selection.py @@ -154,13 +154,12 @@ def test_shift_click_without_anchor(self, model_with_images): model_with_images.clear_selection() model_with_images._last_selected_index = None - # Shift+click without anchor should just select the item + # Shift+click without anchor should just select the single 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 + # When no anchor exists, only the clicked item should be selected selected = model_with_images.get_selected_paths() - assert len(selected) >= 1 + assert len(selected) == 1 class TestFolderSelection: diff --git a/faststack/thumbnail_view/__init__.py b/faststack/thumbnail_view/__init__.py index 2ae8fd0..b6c85ec 100644 --- a/faststack/thumbnail_view/__init__.py +++ b/faststack/thumbnail_view/__init__.py @@ -7,11 +7,11 @@ __all__ = [ "FolderStats", + "PathResolver", "read_folder_stats", - "ThumbnailModel", + "ThumbnailCache", "ThumbnailEntry", + "ThumbnailModel", "ThumbnailPrefetcher", - "ThumbnailCache", "ThumbnailProvider", - "PathResolver", ] diff --git a/faststack/thumbnail_view/folder_stats.py b/faststack/thumbnail_view/folder_stats.py index bf013c5..847e8fd 100644 --- a/faststack/thumbnail_view/folder_stats.py +++ b/faststack/thumbnail_view/folder_stats.py @@ -75,6 +75,11 @@ def _parse_faststack_json(json_path: Path) -> Optional[FolderStats]: log.debug("Failed to parse %s: %s", json_path, e) return None + # Validate JSON root is a dict + if not isinstance(data, dict): + log.debug("Invalid JSON root in %s (expected dict)", json_path) + return None + # Handle different sidecar formats entries = data.get("entries", {}) if not isinstance(entries, dict): diff --git a/faststack/thumbnail_view/provider.py b/faststack/thumbnail_view/provider.py index 5dc18a0..9693c1e 100644 --- a/faststack/thumbnail_view/provider.py +++ b/faststack/thumbnail_view/provider.py @@ -10,6 +10,7 @@ from PySide6.QtQuick import QQuickImageProvider if TYPE_CHECKING: + from faststack.thumbnail_view.model import ThumbnailModel from faststack.thumbnail_view.prefetcher import ThumbnailPrefetcher, ThumbnailCache log = logging.getLogger(__name__) @@ -213,7 +214,8 @@ def update_from_model(self, model: "ThumbnailModel"): for i in range(model.rowCount()): entry = model.get_entry(i) if entry and not entry.is_folder: - path_hash = hashlib.md5( + # MD5 used for cache key only (non-cryptographic) + path_hash = hashlib.md5( # noqa: S324 str(entry.path.resolve()).encode("utf-8") ).hexdigest()[:16] self._hash_to_path[path_hash] = entry.path