Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions faststack/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
70 changes: 48 additions & 22 deletions faststack/imaging/prefetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion faststack/qml/ExifDialog.qml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Dialog {
wrapMode: Text.Wrap
color: exifDialog.textColor
background: null
font.family: "Consolas, monospace"
font.family: "Monospace"
font.pixelSize: 14
}
}
Expand Down
14 changes: 11 additions & 3 deletions faststack/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
Expand Down Expand Up @@ -921,23 +929,23 @@ ApplicationWindow {
Button {
text: "Clear"
visible: uiState ? uiState.gridSelectedCount > 0 : false
onClicked: uiState.gridClearSelection()
onClicked: { if (uiState) uiState.gridClearSelection() }
implicitWidth: 60
implicitHeight: 28
}

// Refresh button
Button {
text: "Refresh"
onClicked: uiState.gridRefresh()
onClicked: { if (uiState) uiState.gridRefresh() }
implicitWidth: 70
implicitHeight: 28
}

// Single Image View button
Button {
text: "Single Image"
onClicked: uiState.toggleGridView()
onClicked: { if (uiState) uiState.toggleGridView() }
implicitWidth: 90
implicitHeight: 28
}
Expand Down
23 changes: 12 additions & 11 deletions faststack/qml/ThumbnailGridView.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}
Expand All @@ -130,14 +129,16 @@ 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()
}
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
Expand Down
11 changes: 7 additions & 4 deletions faststack/qml/ThumbnailTile.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions faststack/tests/thumbnail_view/test_folder_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions faststack/tests/thumbnail_view/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion faststack/tests/thumbnail_view/test_prefetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 3 additions & 4 deletions faststack/tests/thumbnail_view/test_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions faststack/thumbnail_view/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@

__all__ = [
"FolderStats",
"PathResolver",
"read_folder_stats",
"ThumbnailModel",
"ThumbnailCache",
"ThumbnailEntry",
"ThumbnailModel",
"ThumbnailPrefetcher",
"ThumbnailCache",
"ThumbnailProvider",
"PathResolver",
]
5 changes: 5 additions & 0 deletions faststack/thumbnail_view/folder_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 3 additions & 1 deletion faststack/thumbnail_view/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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