Added gallery pane to easily add images on smaller screen devices
All checks were successful
Python CI / test (push) Successful in 1m31s
Lint / lint (push) Successful in 1m10s
Tests / test (3.11) (push) Successful in 1m42s
Tests / test (3.12) (push) Successful in 1m43s
Tests / test (3.13) (push) Successful in 1m36s
Tests / test (3.14) (push) Successful in 1m14s

This commit is contained in:
Duncan Tourolle 2025-12-13 15:30:37 +01:00
parent c66724c190
commit 8f9f387848
7 changed files with 1144 additions and 39 deletions

View File

@ -265,9 +265,14 @@ class AsyncImageLoader(QObject):
logger.info("Stopping AsyncImageLoader...")
self._shutdown = True
# Cancel all active tasks
# Cancel all active tasks and wait for them to finish
if self._loop and not self._loop.is_closed():
asyncio.run_coroutine_threadsafe(self._cancel_all_tasks(), self._loop)
future = asyncio.run_coroutine_threadsafe(self._cancel_all_tasks(), self._loop)
try:
# Wait for cancellation to complete with timeout
future.result(timeout=2.0)
except Exception as e:
logger.warning(f"Error during task cancellation: {e}")
# Stop the event loop
self._loop.call_soon_threadsafe(self._loop.stop)
@ -345,6 +350,10 @@ class AsyncImageLoader(QObject):
target_size = request.target_size
try:
# Check if shutting down
if self._shutdown:
return
# Check cache first
cached_img = self.cache.get(path, target_size)
if cached_img is not None:
@ -356,6 +365,10 @@ class AsyncImageLoader(QObject):
loop = asyncio.get_event_loop()
img = await loop.run_in_executor(self.executor, self._load_and_process_image, path, target_size)
# Check again if shutting down before emitting
if self._shutdown:
return
# Cache result
self.cache.put(path, img, target_size)
@ -364,9 +377,16 @@ class AsyncImageLoader(QObject):
logger.debug(f"Loaded: {path} (size: {img.size})")
except asyncio.CancelledError:
# Task was cancelled during shutdown - this is expected
logger.debug(f"Load cancelled for {path}")
raise # Re-raise to properly cancel the task
except Exception as e:
logger.error(f"Failed to load {path}: {e}", exc_info=True)
self._emit_failed(path, str(e), request.user_data)
# Only emit error if not shutting down
if not self._shutdown:
logger.error(f"Failed to load {path}: {e}", exc_info=True)
self._emit_failed(path, str(e), request.user_data)
finally:
# Cleanup tracking
@ -400,11 +420,25 @@ class AsyncImageLoader(QObject):
def _emit_loaded(self, path: Path, img: Image.Image, user_data: Any):
"""Emit image_loaded signal (thread-safe)."""
self.image_loaded.emit(path, img, user_data)
# Check if object is still valid before emitting
if self._shutdown:
return
try:
self.image_loaded.emit(path, img, user_data)
except RuntimeError as e:
# Object was deleted - log but don't crash
logger.debug(f"Could not emit image_loaded for {path}: {e}")
def _emit_failed(self, path: Path, error_msg: str, user_data: Any):
"""Emit load_failed signal (thread-safe)."""
self.load_failed.emit(path, error_msg, user_data)
# Check if object is still valid before emitting
if self._shutdown:
return
try:
self.load_failed.emit(path, error_msg, user_data)
except RuntimeError as e:
# Object was deleted - log but don't crash
logger.debug(f"Could not emit load_failed for {path}: {e}")
def request_load(
self,
@ -646,9 +680,14 @@ class AsyncPDFGenerator(QObject):
# Progress callback wrapper
def progress_callback(current, total, message):
if self._cancel_requested:
if self._cancel_requested or self._shutdown:
return False # Signal cancellation
self.progress_updated.emit(current, total, message)
try:
self.progress_updated.emit(current, total, message)
except RuntimeError as e:
# Object was deleted - log but don't crash
logger.debug(f"Could not emit progress_updated: {e}")
return False
return True
# Run export in thread pool
@ -658,19 +697,30 @@ class AsyncPDFGenerator(QObject):
)
# Emit completion signal
if not self._cancel_requested:
self.export_complete.emit(success, warnings)
logger.info(f"PDF export completed: {output_path} (warnings: {len(warnings)})")
if not self._cancel_requested and not self._shutdown:
try:
self.export_complete.emit(success, warnings)
logger.info(f"PDF export completed: {output_path} (warnings: {len(warnings)})")
except RuntimeError as e:
logger.debug(f"Could not emit export_complete: {e}")
else:
logger.info("PDF export cancelled")
except asyncio.CancelledError:
logger.info("PDF export cancelled by user")
self.export_failed.emit("Export cancelled")
if not self._shutdown:
try:
self.export_failed.emit("Export cancelled")
except RuntimeError as e:
logger.debug(f"Could not emit export_failed: {e}")
except Exception as e:
logger.error(f"PDF export failed: {e}", exc_info=True)
self.export_failed.emit(str(e))
if not self._shutdown:
try:
self.export_failed.emit(str(e))
except RuntimeError as e:
logger.debug(f"Could not emit export_failed: {e}")
finally:
with self._lock:

View File

@ -27,6 +27,7 @@ from pyPhotoAlbum.ribbon_widget import RibbonWidget
from pyPhotoAlbum.ribbon_builder import build_ribbon_config, print_ribbon_summary
from pyPhotoAlbum.gl_widget import GLWidget
from pyPhotoAlbum.autosave_manager import AutosaveManager
from pyPhotoAlbum.thumbnail_browser import ThumbnailBrowserDock
# Import mixins
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
@ -174,6 +175,11 @@ class MainWindow(
self.setCentralWidget(main_widget)
# Create thumbnail browser dock
self._thumbnail_browser = ThumbnailBrowserDock(self)
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self._thumbnail_browser)
self._thumbnail_browser.hide() # Initially hidden
# Create status bar
self._status_bar = QStatusBar()
self.setStatusBar(self._status_bar)

View File

@ -175,6 +175,23 @@ class ViewOperationsMixin:
self.show_status(f"Cleared {guide_count} guides", 2000)
print(f"Cleared {guide_count} guides")
@ribbon_action(
label="Image Browser",
tooltip="Show/hide the image browser panel",
tab="View",
group="Panels",
shortcut="Ctrl+B"
)
def toggle_image_browser(self):
"""Toggle the thumbnail browser visibility"""
if hasattr(self, '_thumbnail_browser'):
if self._thumbnail_browser.isVisible():
self._thumbnail_browser.hide()
self.show_status("Image browser hidden", 2000)
else:
self._thumbnail_browser.show()
self.show_status("Image browser shown", 2000)
@ribbon_action(
label="Grid Settings...", tooltip="Configure grid size and snap threshold", tab="Insert", group="Snapping"
)

View File

@ -561,29 +561,18 @@ class TextBoxData(BaseLayoutElement):
# Now render at origin (rotation pivot is at element center)
x, y = 0, 0
# Enable alpha blending for transparency
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Draw a semi-transparent yellow rectangle as text box background
glColor4f(1.0, 1.0, 0.7, 0.3) # Light yellow with 30% opacity
glBegin(GL_QUADS)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
glDisable(GL_BLEND)
# Draw border
glColor3f(0.0, 0.0, 0.0) # Black border
# No background fill - text boxes are transparent in final output
# Just draw a light dashed border for editing visibility
glEnable(GL_LINE_STIPPLE)
glLineStipple(2, 0xAAAA) # Dashed line pattern
glColor3f(0.7, 0.7, 0.7) # Light gray border
glBegin(GL_LINE_LOOP)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
glDisable(GL_LINE_STIPPLE)
# Pop matrix if we pushed for rotation
if self.rotation != 0:

View File

@ -198,13 +198,9 @@ class PageLayout:
snap_lines = temp_snap_sys.get_snap_lines(self.size, dpi)
# Enable alpha blending for transparency
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Draw grid lines (darker gray with transparency) - visible when show_grid is enabled
# Draw grid lines (light gray, fully opaque) - visible when show_grid is enabled
if show_grid and snap_lines["grid"]:
glColor4f(0.6, 0.6, 0.6, 0.4) # Gray with 40% opacity
glColor3f(0.8, 0.8, 0.8) # Light gray, fully opaque
glLineWidth(1.0)
for orientation, position in snap_lines["grid"]:
glBegin(GL_LINES)
@ -216,9 +212,9 @@ class PageLayout:
glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position)
glEnd()
# Draw guides (cyan, more visible with transparency) - only show when show_snap_lines is on
# Draw guides (cyan, fully opaque) - only show when show_snap_lines is on
if show_snap_lines and snap_lines["guides"]:
glColor4f(0.0, 0.7, 0.9, 0.8) # Cyan with 80% opacity
glColor3f(0.0, 0.7, 0.9) # Cyan, fully opaque
glLineWidth(1.5)
for orientation, position in snap_lines["guides"]:
glBegin(GL_LINES)
@ -231,7 +227,6 @@ class PageLayout:
glEnd()
glLineWidth(1.0)
glDisable(GL_BLEND)
def serialize(self) -> Dict[str, Any]:
"""Serialize page layout to dictionary"""

View File

@ -0,0 +1,575 @@
"""
Thumbnail Browser Widget - displays thumbnails from a folder for drag-and-drop into album.
"""
import os
from pathlib import Path
from typing import Optional, List, Tuple
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QFileDialog, QDockWidget
)
from PyQt6.QtCore import Qt, QSize, QMimeData, QUrl, QPoint
from PyQt6.QtGui import QDrag, QCursor
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.gl_imports import *
from pyPhotoAlbum.mixins.viewport import ViewportMixin
IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"]
class ThumbnailItem:
"""Represents a thumbnail with position and path information."""
def __init__(self, image_path: str, grid_pos: Tuple[int, int], thumbnail_size: float = 100.0):
self.image_path = image_path
self.grid_row, self.grid_col = grid_pos
self.thumbnail_size = thumbnail_size
self.is_used_in_project = False # Will be updated when checking against project
# Position in mm (will be calculated based on grid)
spacing = 10.0 # mm spacing between thumbnails
self.x = self.grid_col * (self.thumbnail_size + spacing) + spacing
self.y = self.grid_row * (self.thumbnail_size + spacing) + spacing
# Texture info (loaded async)
self._texture_id = None
self._pending_pil_image = None
self._async_loading = False
self._img_width = None
self._img_height = None
def get_bounds(self) -> Tuple[float, float, float, float]:
"""Return (x, y, width, height) bounds."""
return (self.x, self.y, self.thumbnail_size, self.thumbnail_size)
def contains_point(self, x: float, y: float) -> bool:
"""Check if point is inside this thumbnail."""
return (self.x <= x <= self.x + self.thumbnail_size and
self.y <= y <= self.y + self.thumbnail_size)
class ThumbnailGLWidget(QOpenGLWidget):
"""
OpenGL widget that displays thumbnails in a grid.
Uses the same async loading and texture system as the main canvas.
"""
def __init__(self, main_window=None):
super().__init__()
self.thumbnails: List[ThumbnailItem] = []
self.current_folder: Optional[Path] = None
# Store reference to main window
self._main_window = main_window
# Viewport state
self.zoom_level = 1.0
self.pan_offset = (0, 0)
# Dragging state
self.drag_start_pos = None
self.dragging_thumbnail = None
# Enable OpenGL
self.setMinimumSize(QSize(250, 300))
def window(self):
"""Override window() to return stored main_window reference."""
return self._main_window if self._main_window else super().window()
def update(self):
"""Override update to batch repaints for better performance."""
# Just schedule the update - Qt will automatically batch multiple
# update() calls into a single paintGL() invocation
super().update()
def initializeGL(self):
"""Initialize OpenGL context."""
glClearColor(0.95, 0.95, 0.95, 1.0) # Light gray background
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glEnable(GL_TEXTURE_2D)
def resizeGL(self, w, h):
"""Handle resize events."""
glViewport(0, 0, w, h)
glMatrixMode(GL_PROJECTION)
glLoadIdentity()
glOrtho(0, w, h, 0, -1, 1) # 2D orthographic projection
glMatrixMode(GL_MODELVIEW)
# Rearrange thumbnails to fit new width
if hasattr(self, 'image_files') and self.image_files:
self._arrange_thumbnails()
def paintGL(self):
"""Render thumbnails."""
glClear(GL_COLOR_BUFFER_BIT)
glLoadIdentity()
if not self.thumbnails:
return
# Apply zoom and pan
glTranslatef(self.pan_offset[0], self.pan_offset[1], 0)
glScalef(self.zoom_level, self.zoom_level, 1.0)
# Render each thumbnail (placeholders or textures)
for thumb in self.thumbnails:
self._render_thumbnail(thumb)
def _render_thumbnail(self, thumb: ThumbnailItem):
"""Render a single thumbnail using placeholder pattern."""
x, y, w, h = thumb.get_bounds()
# If we have a pending image, convert it to texture (happens once per image)
if hasattr(thumb, "_pending_pil_image") and thumb._pending_pil_image is not None:
self._create_texture_for_thumbnail(thumb)
# Render based on state: texture, loading placeholder, or empty placeholder
if thumb._texture_id:
# Calculate aspect-ratio-corrected dimensions
if hasattr(thumb, '_img_width') and hasattr(thumb, '_img_height'):
img_aspect = thumb._img_width / thumb._img_height
thumb_aspect = w / h
if img_aspect > thumb_aspect:
# Image is wider - fit to width
render_w = w
render_h = w / img_aspect
render_x = x
render_y = y + (h - render_h) / 2
else:
# Image is taller - fit to height
render_h = h
render_w = h * img_aspect
render_x = x + (w - render_w) / 2
render_y = y
else:
# No aspect ratio info, use full bounds
render_x, render_y, render_w, render_h = x, y, w, h
# Render actual texture
glEnable(GL_TEXTURE_2D)
glBindTexture(GL_TEXTURE_2D, thumb._texture_id)
# If used in project, desaturate by tinting grey
if thumb.is_used_in_project:
glColor4f(0.5, 0.5, 0.5, 0.6) # Grey tint + partial transparency
else:
glColor4f(1.0, 1.0, 1.0, 1.0)
glBegin(GL_QUADS)
glTexCoord2f(0.0, 0.0)
glVertex2f(render_x, render_y)
glTexCoord2f(1.0, 0.0)
glVertex2f(render_x + render_w, render_y)
glTexCoord2f(1.0, 1.0)
glVertex2f(render_x + render_w, render_y + render_h)
glTexCoord2f(0.0, 1.0)
glVertex2f(render_x, render_y + render_h)
glEnd()
glDisable(GL_TEXTURE_2D)
else:
# Render placeholder (grey box while loading or if load failed)
glColor3f(0.8, 0.8, 0.8)
glBegin(GL_QUADS)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
# Border
glColor3f(0.5, 0.5, 0.5)
glLineWidth(1.0)
glBegin(GL_LINE_LOOP)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
def _create_texture_for_thumbnail(self, thumb: ThumbnailItem):
"""Create OpenGL texture from pending PIL image."""
if not thumb._pending_pil_image:
return False
try:
pil_image = thumb._pending_pil_image
# Ensure RGBA
if pil_image.mode != "RGBA":
pil_image = pil_image.convert("RGBA")
# Delete old texture
if thumb._texture_id:
glDeleteTextures([thumb._texture_id])
# Create texture
img_data = pil_image.tobytes()
texture_id = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, texture_id)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexImage2D(
GL_TEXTURE_2D, 0, GL_RGBA,
pil_image.width, pil_image.height,
0, GL_RGBA, GL_UNSIGNED_BYTE, img_data
)
thumb._texture_id = texture_id
thumb._img_width = pil_image.width
thumb._img_height = pil_image.height
thumb._pending_pil_image = None
return True
except Exception as e:
print(f"Error creating texture for thumbnail: {e}")
thumb._pending_pil_image = None
return False
def load_folder(self, folder_path: Path):
"""Load thumbnails from a folder."""
self.current_folder = folder_path
# Find all image files
self.image_files = []
for ext in IMAGE_EXTENSIONS:
self.image_files.extend(folder_path.glob(f"*{ext}"))
self.image_files.extend(folder_path.glob(f"*{ext.upper()}"))
self.image_files.sort()
# Arrange thumbnails based on current widget size and zoom
self._arrange_thumbnails()
# Update which images are already in use
self.update_used_images()
self.update()
def _arrange_thumbnails(self):
"""Arrange thumbnails in a grid based on widget width and zoom level."""
if not hasattr(self, 'image_files') or not self.image_files:
self.thumbnails.clear()
return
# Calculate number of columns that fit
widget_width = self.width()
if widget_width <= 0:
widget_width = 250 # Default minimum width
# Thumbnail size in screen pixels (affected by zoom)
thumb_size_screen = 100.0 * self.zoom_level
spacing_screen = 10.0 * self.zoom_level
# Calculate columns
columns = max(1, int((widget_width - spacing_screen) / (thumb_size_screen + spacing_screen)))
# Calculate total grid width to center it
spacing = 10.0
grid_width = columns * (100.0 + spacing) - spacing # Total width in base units
# Horizontal offset to center the grid
h_offset = max(0, (widget_width / self.zoom_level - grid_width) / 2)
# Build a map of existing thumbnails by path to reuse them
existing_thumbs = {thumb.image_path: thumb for thumb in self.thumbnails}
# Clear list but reuse thumbnail objects
self.thumbnails.clear()
for idx, image_file in enumerate(self.image_files):
row = idx // columns
col = idx % columns
image_path = str(image_file)
# Reuse existing thumbnail if available, otherwise create new
if image_path in existing_thumbs:
thumb = existing_thumbs[image_path]
# Update grid position
thumb.grid_row = row
thumb.grid_col = col
# Recalculate position with horizontal centering
thumb.x = h_offset + col * (thumb.thumbnail_size + spacing) + spacing
thumb.y = row * (thumb.thumbnail_size + spacing) + spacing
else:
# Create new placeholder thumbnail with horizontal centering
thumb = ThumbnailItem(image_path, (row, col))
thumb.x = h_offset + col * (thumb.thumbnail_size + spacing) + spacing
thumb.y = row * (thumb.thumbnail_size + spacing) + spacing
# Request async load (will be skipped if already loading/loaded)
self._request_thumbnail_load(thumb)
self.thumbnails.append(thumb)
def update_used_images(self):
"""Update which thumbnails are already used in the project."""
# Get reference to main window's project
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project:
return
project = main_window.project
# Collect all image paths used in the project
used_paths = set()
for page in project.pages:
from pyPhotoAlbum.models import ImageData
for element in page.layout.elements:
if isinstance(element, ImageData) and element.image_path:
# Resolve to absolute path for comparison
abs_path = element.resolve_image_path()
if abs_path:
used_paths.add(abs_path)
# Mark thumbnails as used
for thumb in self.thumbnails:
thumb.is_used_in_project = thumb.image_path in used_paths
def _request_thumbnail_load(self, thumb: ThumbnailItem):
"""Request async load for a thumbnail using main window's loader."""
# Skip if already loading or loaded
if thumb._async_loading or thumb._texture_id:
return
# Get main window's async loader
main_window = self.window()
if not main_window or not hasattr(main_window, '_gl_widget'):
return
gl_widget = main_window._gl_widget
if not hasattr(gl_widget, 'async_image_loader'):
return
from pyPhotoAlbum.async_backend import LoadPriority
try:
# Mark as loading to prevent duplicate requests
thumb._async_loading = True
# Request load through main window's async loader
# Use LOW priority for thumbnails to not interfere with main canvas
gl_widget.async_image_loader.request_load(
Path(thumb.image_path),
priority=LoadPriority.LOW,
target_size=(200, 200), # Small thumbnails
user_data=thumb
)
except RuntimeError:
thumb._async_loading = False # Reset on error
def _on_image_loaded(self, path: Path, image, user_data):
"""Handle async image loaded - sets pending image on the placeholder."""
if isinstance(user_data, ThumbnailItem):
# Store the loaded image in the placeholder
user_data._pending_pil_image = image
user_data._img_width = image.width
user_data._img_height = image.height
user_data._async_loading = False
# Schedule a repaint (will be batched if many images load quickly)
self.update()
def _on_image_load_failed(self, path: Path, error_msg: str, user_data):
"""Handle async image load failure."""
pass # Silently ignore load failures for thumbnails
def screen_to_viewport(self, screen_x: int, screen_y: int) -> Tuple[float, float]:
"""Convert screen coordinates to viewport coordinates (accounting for zoom/pan)."""
vp_x = (screen_x - self.pan_offset[0]) / self.zoom_level
vp_y = (screen_y - self.pan_offset[1]) / self.zoom_level
return vp_x, vp_y
def get_thumbnail_at(self, screen_x: int, screen_y: int) -> Optional[ThumbnailItem]:
"""Get thumbnail at screen position."""
vp_x, vp_y = self.screen_to_viewport(screen_x, screen_y)
for thumb in self.thumbnails:
if thumb.contains_point(vp_x, vp_y):
return thumb
return None
def mousePressEvent(self, event):
"""Handle mouse press for drag."""
if event.button() == Qt.MouseButton.LeftButton:
self.drag_start_pos = event.pos()
self.dragging_thumbnail = self.get_thumbnail_at(event.pos().x(), event.pos().y())
def mouseMoveEvent(self, event):
"""Handle mouse move for drag or pan."""
if not (event.buttons() & Qt.MouseButton.LeftButton):
return
if self.drag_start_pos is None:
return
# Check if we should start dragging a thumbnail
if self.dragging_thumbnail:
# Start drag operation
drag = QDrag(self)
mime_data = QMimeData()
# Set file URL for the drag
url = QUrl.fromLocalFile(self.dragging_thumbnail.image_path)
mime_data.setUrls([url])
drag.setMimeData(mime_data)
# Execute drag (this blocks until drop or cancel)
drag.exec(Qt.DropAction.CopyAction)
# Reset drag state
self.drag_start_pos = None
self.dragging_thumbnail = None
else:
# Pan the view (right-click or middle-click drag)
# Only allow vertical panning - grid is always horizontally centered
delta = event.pos() - self.drag_start_pos
self.pan_offset = (
0, # No horizontal pan - grid is centered
self.pan_offset[1] + delta.y()
)
self.drag_start_pos = event.pos()
self.update()
def mouseReleaseEvent(self, event):
"""Handle mouse release."""
self.drag_start_pos = None
self.dragging_thumbnail = None
def wheelEvent(self, event):
"""Handle mouse wheel for scrolling (or zooming with Ctrl)."""
delta = event.angleDelta().y()
# Check if Ctrl is pressed for zooming
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
# Zoom mode
mouse_y = event.position().y()
zoom_factor = 1.1 if delta > 0 else 0.9
# Calculate vertical world position before zoom
world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
# Apply zoom
old_zoom = self.zoom_level
self.zoom_level *= zoom_factor
self.zoom_level = max(0.1, min(5.0, self.zoom_level)) # Clamp
# Rearrange thumbnails if zoom level changed significantly
# This recalculates horizontal centering
if abs(self.zoom_level - old_zoom) > 0.01:
self._arrange_thumbnails()
# Adjust vertical pan to keep mouse position fixed
# Keep horizontal pan at 0 (grid is always horizontally centered)
self.pan_offset = (
0, # No horizontal pan - grid is centered in _arrange_thumbnails
mouse_y - world_y * self.zoom_level
)
else:
# Scroll mode - scroll vertically only
scroll_amount = delta * 0.5 # Adjust sensitivity
self.pan_offset = (
0, # No horizontal pan
self.pan_offset[1] + scroll_amount
)
self.update()
class ThumbnailBrowserDock(QDockWidget):
"""
Dockable widget containing the thumbnail browser.
"""
def __init__(self, parent=None):
super().__init__("Image Browser", parent)
# Create main widget
main_widget = QWidget()
layout = QVBoxLayout(main_widget)
layout.setContentsMargins(5, 5, 5, 5)
layout.setSpacing(5)
# Header with folder selection
header_layout = QHBoxLayout()
self.folder_label = QLabel("No folder selected")
self.folder_label.setStyleSheet("font-weight: bold; padding: 5px;")
header_layout.addWidget(self.folder_label)
self.select_folder_btn = QPushButton("Select Folder...")
self.select_folder_btn.clicked.connect(self._select_folder)
header_layout.addWidget(self.select_folder_btn)
layout.addLayout(header_layout)
# GL Widget for thumbnails
self.gl_widget = ThumbnailGLWidget(main_window=parent)
layout.addWidget(self.gl_widget)
self.setWidget(main_widget)
# Dock settings
self.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea |
Qt.DockWidgetArea.RightDockWidgetArea)
self.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable |
QDockWidget.DockWidgetFeature.DockWidgetMovable |
QDockWidget.DockWidgetFeature.DockWidgetFloatable)
# Connect to main window's async loader when shown
self._connect_async_loader()
def _connect_async_loader(self):
"""Connect to main window's async image loader."""
main_window = self.window()
if not hasattr(main_window, '_gl_widget'):
return
gl_widget = main_window._gl_widget
if not hasattr(gl_widget, 'async_image_loader'):
return
# Avoid duplicate connections
if hasattr(self, '_async_connected') and self._async_connected:
return
try:
# Connect signals
gl_widget.async_image_loader.image_loaded.connect(self.gl_widget._on_image_loaded)
gl_widget.async_image_loader.load_failed.connect(self.gl_widget._on_image_load_failed)
self._async_connected = True
except Exception:
pass # Silently handle connection errors
def showEvent(self, event):
"""Handle show event."""
super().showEvent(event)
# Ensure async loader is connected when shown
self._connect_async_loader()
def _select_folder(self):
"""Open dialog to select folder."""
folder_path = QFileDialog.getExistingDirectory(
self,
"Select Image Folder",
str(self.gl_widget.current_folder) if self.gl_widget.current_folder else str(Path.home()),
QFileDialog.Option.ShowDirsOnly
)
if folder_path:
self.load_folder(Path(folder_path))
def load_folder(self, folder_path: Path):
"""Load thumbnails from folder."""
self.folder_label.setText(f"Folder: {folder_path.name}")
self.gl_widget.load_folder(folder_path)

View File

@ -0,0 +1,473 @@
"""
Unit tests for the thumbnail browser functionality.
"""
import unittest
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch
import tempfile
import os
import time
from PyQt6.QtWidgets import QApplication
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtTest import QTest
from pyPhotoAlbum.thumbnail_browser import ThumbnailItem, ThumbnailGLWidget, ThumbnailBrowserDock
from pyPhotoAlbum.models import ImageData
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.project import Project, Page
try:
from PIL import Image
PILLOW_AVAILABLE = True
except ImportError:
PILLOW_AVAILABLE = False
class TestThumbnailItem(unittest.TestCase):
"""Test ThumbnailItem class."""
def test_thumbnail_item_initialization(self):
"""Test ThumbnailItem initializes correctly."""
item = ThumbnailItem("/path/to/image.jpg", (0, 0), 100.0)
self.assertEqual(item.image_path, "/path/to/image.jpg")
self.assertEqual(item.grid_row, 0)
self.assertEqual(item.grid_col, 0)
self.assertEqual(item.thumbnail_size, 100.0)
self.assertFalse(item.is_used_in_project)
def test_thumbnail_item_position_calculation(self):
"""Test that thumbnail position is calculated correctly based on grid."""
# Position (0, 0)
item1 = ThumbnailItem("/path/1.jpg", (0, 0), 100.0)
self.assertEqual(item1.x, 10.0) # spacing
self.assertEqual(item1.y, 10.0) # spacing
# Position (0, 1) - second column
item2 = ThumbnailItem("/path/2.jpg", (0, 1), 100.0)
self.assertEqual(item2.x, 120.0) # 10 + (100 + 10) * 1
self.assertEqual(item2.y, 10.0)
# Position (1, 0) - second row
item3 = ThumbnailItem("/path/3.jpg", (1, 0), 100.0)
self.assertEqual(item3.x, 10.0)
self.assertEqual(item3.y, 120.0) # 10 + (100 + 10) * 1
def test_thumbnail_item_bounds(self):
"""Test get_bounds returns correct values."""
item = ThumbnailItem("/path/to/image.jpg", (0, 0), 100.0)
bounds = item.get_bounds()
self.assertEqual(bounds, (10.0, 10.0, 100.0, 100.0))
def test_thumbnail_item_contains_point(self):
"""Test contains_point correctly detects if point is inside thumbnail."""
item = ThumbnailItem("/path/to/image.jpg", (0, 0), 100.0)
# Point inside
self.assertTrue(item.contains_point(50.0, 50.0))
self.assertTrue(item.contains_point(10.0, 10.0)) # Top-left corner
self.assertTrue(item.contains_point(110.0, 110.0)) # Bottom-right corner
# Points outside
self.assertFalse(item.contains_point(5.0, 5.0))
self.assertFalse(item.contains_point(120.0, 120.0))
self.assertFalse(item.contains_point(50.0, 150.0))
class TestThumbnailGLWidget(unittest.TestCase):
"""Test ThumbnailGLWidget class."""
@classmethod
def setUpClass(cls):
"""Set up QApplication for tests."""
if not QApplication.instance():
cls.app = QApplication([])
else:
cls.app = QApplication.instance()
def setUp(self):
"""Set up test fixtures."""
self.widget = ThumbnailGLWidget(main_window=None)
def test_widget_initialization(self):
"""Test widget initializes with correct defaults."""
self.assertEqual(len(self.widget.thumbnails), 0)
self.assertIsNone(self.widget.current_folder)
self.assertEqual(self.widget.zoom_level, 1.0)
self.assertEqual(self.widget.pan_offset, (0, 0))
def test_screen_to_viewport_conversion(self):
"""Test screen to viewport coordinate conversion."""
self.widget.zoom_level = 2.0
self.widget.pan_offset = (10, 20)
vp_x, vp_y = self.widget.screen_to_viewport(50, 60)
# (50 - 10) / 2.0 = 20.0
# (60 - 20) / 2.0 = 20.0
self.assertEqual(vp_x, 20.0)
self.assertEqual(vp_y, 20.0)
def test_load_folder_with_no_images(self):
"""Test loading a folder with no images."""
with tempfile.TemporaryDirectory() as tmpdir:
self.widget.load_folder(Path(tmpdir))
self.assertEqual(self.widget.current_folder, Path(tmpdir))
self.assertEqual(len(self.widget.thumbnails), 0)
@patch('pyPhotoAlbum.thumbnail_browser.ThumbnailGLWidget._request_thumbnail_load')
def test_load_folder_with_images(self, mock_request_load):
"""Test loading a folder with image files."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create some dummy image files
img1 = Path(tmpdir) / "image1.jpg"
img2 = Path(tmpdir) / "image2.png"
img3 = Path(tmpdir) / "image3.gif"
img1.touch()
img2.touch()
img3.touch()
self.widget.load_folder(Path(tmpdir))
self.assertEqual(self.widget.current_folder, Path(tmpdir))
self.assertEqual(len(self.widget.thumbnails), 3)
self.assertEqual(len(self.widget.image_files), 3)
# Check that all thumbnails have valid grid positions
for thumb in self.widget.thumbnails:
self.assertGreaterEqual(thumb.grid_row, 0)
self.assertGreaterEqual(thumb.grid_col, 0)
# Verify load was requested for each thumbnail
self.assertEqual(mock_request_load.call_count, 3)
def test_get_thumbnail_at_position(self):
"""Test getting thumbnail at a specific screen position."""
# Manually add some thumbnails
thumb1 = ThumbnailItem("/path/1.jpg", (0, 0), 100.0)
thumb2 = ThumbnailItem("/path/2.jpg", (0, 1), 100.0)
self.widget.thumbnails = [thumb1, thumb2]
# No zoom or pan
self.widget.zoom_level = 1.0
self.widget.pan_offset = (0, 0)
# Point inside first thumbnail
result = self.widget.get_thumbnail_at(50, 50)
self.assertEqual(result, thumb1)
# Point inside second thumbnail
result = self.widget.get_thumbnail_at(130, 50)
self.assertEqual(result, thumb2)
# Point outside both thumbnails
result = self.widget.get_thumbnail_at(300, 300)
self.assertIsNone(result)
def test_update_used_images(self):
"""Test that used images are correctly marked."""
# Create mock main window with project
mock_main_window = Mock()
mock_project = Mock(spec=Project)
# Create mock pages with image elements
mock_layout = Mock(spec=PageLayout)
mock_page = Mock(spec=Page)
mock_page.layout = mock_layout
# Create image element that uses /path/to/used.jpg
mock_image = Mock(spec=ImageData)
mock_image.image_path = "assets/used.jpg"
mock_image.resolve_image_path.return_value = "/path/to/used.jpg"
mock_layout.elements = [mock_image]
mock_project.pages = [mock_page]
mock_main_window.project = mock_project
# Mock the window() method to return our mock main window
with patch.object(self.widget, 'window', return_value=mock_main_window):
# Add thumbnails
thumb1 = ThumbnailItem("/path/to/used.jpg", (0, 0))
thumb2 = ThumbnailItem("/path/to/unused.jpg", (0, 1))
self.widget.thumbnails = [thumb1, thumb2]
# Update used images
self.widget.update_used_images()
# Check results
self.assertTrue(thumb1.is_used_in_project)
self.assertFalse(thumb2.is_used_in_project)
class TestThumbnailBrowserDock(unittest.TestCase):
"""Test ThumbnailBrowserDock class."""
@classmethod
def setUpClass(cls):
"""Set up QApplication for tests."""
if not QApplication.instance():
cls.app = QApplication([])
else:
cls.app = QApplication.instance()
def setUp(self):
"""Set up test fixtures."""
self.dock = ThumbnailBrowserDock()
def test_dock_initialization(self):
"""Test dock widget initializes correctly."""
self.assertEqual(self.dock.windowTitle(), "Image Browser")
self.assertIsNotNone(self.dock.gl_widget)
self.assertIsNotNone(self.dock.folder_label)
self.assertIsNotNone(self.dock.select_folder_btn)
def test_initial_folder_label(self):
"""Test initial folder label text."""
self.assertEqual(self.dock.folder_label.text(), "No folder selected")
@patch('pyPhotoAlbum.thumbnail_browser.QFileDialog.getExistingDirectory')
@patch('pyPhotoAlbum.thumbnail_browser.ThumbnailGLWidget.load_folder')
def test_select_folder(self, mock_load_folder, mock_dialog):
"""Test folder selection updates the widget."""
# Mock the dialog to return a path
test_path = "/test/folder"
mock_dialog.return_value = test_path
# Trigger folder selection
self.dock._select_folder()
# Verify dialog was called
mock_dialog.assert_called_once()
# Verify load_folder was called with the path
mock_load_folder.assert_called_once_with(Path(test_path))
@patch('pyPhotoAlbum.thumbnail_browser.QFileDialog.getExistingDirectory')
@patch('pyPhotoAlbum.thumbnail_browser.ThumbnailGLWidget.load_folder')
def test_select_folder_cancel(self, mock_load_folder, mock_dialog):
"""Test folder selection handles cancel."""
# Mock the dialog to return empty (cancel)
mock_dialog.return_value = ""
# Trigger folder selection
self.dock._select_folder()
# Verify load_folder was NOT called
mock_load_folder.assert_not_called()
def test_load_folder_updates_label(self):
"""Test that loading a folder updates the label."""
with tempfile.TemporaryDirectory() as tmpdir:
folder_path = Path(tmpdir)
folder_name = folder_path.name
self.dock.load_folder(folder_path)
self.assertEqual(self.dock.folder_label.text(), f"Folder: {folder_name}")
@unittest.skipUnless(PILLOW_AVAILABLE, "Pillow not available")
class TestThumbnailBrowserIntegration(unittest.TestCase):
"""Integration tests for thumbnail browser with actual image files."""
@classmethod
def setUpClass(cls):
"""Set up QApplication for tests."""
if not QApplication.instance():
cls.app = QApplication([])
else:
cls.app = QApplication.instance()
def setUp(self):
"""Set up test fixtures."""
self.widget = ThumbnailGLWidget(main_window=None)
def tearDown(self):
"""Clean up after tests."""
if hasattr(self.widget, 'thumbnails'):
# Clean up any GL textures
for thumb in self.widget.thumbnails:
if hasattr(thumb, '_texture_id') and thumb._texture_id:
try:
from pyPhotoAlbum.gl_imports import glDeleteTextures
glDeleteTextures([thumb._texture_id])
except:
pass
def _create_test_jpeg(self, path: Path, width: int = 100, height: int = 100, color: tuple = (255, 0, 0)):
"""Create a test JPEG file with the specified dimensions and color."""
img = Image.new('RGB', (width, height), color=color)
img.save(path, 'JPEG', quality=85)
def test_load_folder_with_real_jpegs(self):
"""Integration test: Load a folder with real JPEG files."""
with tempfile.TemporaryDirectory() as tmpdir:
folder = Path(tmpdir)
# Create test JPEG files with different colors
colors = [
(255, 0, 0), # Red
(0, 255, 0), # Green
(0, 0, 255), # Blue
(255, 255, 0), # Yellow
(255, 0, 255), # Magenta
]
created_files = []
for i, color in enumerate(colors):
img_path = folder / f"test_image_{i:02d}.jpg"
self._create_test_jpeg(img_path, 200, 150, color)
created_files.append(img_path)
# Load the folder
self.widget.load_folder(folder)
# Verify folder was set
self.assertEqual(self.widget.current_folder, folder)
# Verify image files were found
self.assertEqual(len(self.widget.image_files), 5)
self.assertEqual(len(self.widget.thumbnails), 5)
# Verify all created files are in the list
found_paths = [str(f) for f in self.widget.image_files]
for created_file in created_files:
self.assertIn(str(created_file), found_paths)
# Verify grid positions are valid
for thumb in self.widget.thumbnails:
self.assertGreaterEqual(thumb.grid_row, 0)
self.assertGreaterEqual(thumb.grid_col, 0)
self.assertTrue(thumb.image_path.endswith('.jpg'))
def test_thumbnail_async_loading_with_mock_loader(self):
"""Test thumbnail loading with a mock async loader."""
with tempfile.TemporaryDirectory() as tmpdir:
folder = Path(tmpdir)
# Create 3 test images
for i in range(3):
img_path = folder / f"image_{i}.jpg"
self._create_test_jpeg(img_path, 150, 150, (100 + i * 50, 100, 100))
# Create a mock main window with async loader and project
mock_main_window = Mock()
mock_gl_widget = Mock()
mock_async_loader = Mock()
mock_project = Mock()
mock_project.pages = [] # Empty pages list
# Track requested loads
requested_loads = []
def mock_request_load(path, priority, target_size, user_data):
requested_loads.append({
'path': path,
'user_data': user_data
})
# Simulate immediate load by loading the image
try:
img = Image.open(path)
img = img.convert('RGBA')
img.thumbnail(target_size, Image.Resampling.LANCZOS)
# Call the callback directly
user_data._pending_pil_image = img
user_data._img_width = img.width
user_data._img_height = img.height
except Exception as e:
print(f"Error in mock load: {e}")
mock_async_loader.request_load = mock_request_load
mock_gl_widget.async_image_loader = mock_async_loader
mock_main_window._gl_widget = mock_gl_widget
mock_main_window.project = mock_project
# Patch the widget's window() method
with patch.object(self.widget, 'window', return_value=mock_main_window):
# Load the folder
self.widget.load_folder(folder)
# Verify load was requested for each image
self.assertEqual(len(requested_loads), 3)
# Verify images were "loaded" (pending images set)
loaded_count = sum(1 for thumb in self.widget.thumbnails
if hasattr(thumb, '_pending_pil_image') and thumb._pending_pil_image)
self.assertEqual(loaded_count, 3)
# Verify image dimensions were set
for thumb in self.widget.thumbnails:
if hasattr(thumb, '_img_width'):
self.assertGreater(thumb._img_width, 0)
self.assertGreater(thumb._img_height, 0)
def test_large_folder_loading(self):
"""Test loading a folder with many images."""
with tempfile.TemporaryDirectory() as tmpdir:
folder = Path(tmpdir)
# Create 50 test images
num_images = 50
for i in range(num_images):
img_path = folder / f"img_{i:03d}.jpg"
# Use smaller images for speed
color = (i * 5 % 256, (i * 7) % 256, (i * 11) % 256)
self._create_test_jpeg(img_path, 50, 50, color)
# Load the folder
self.widget.load_folder(folder)
# Verify all images were found
self.assertEqual(len(self.widget.image_files), num_images)
self.assertEqual(len(self.widget.thumbnails), num_images)
# Verify grid layout exists and all positions are valid
for thumb in self.widget.thumbnails:
self.assertGreaterEqual(thumb.grid_row, 0)
self.assertGreaterEqual(thumb.grid_col, 0)
def test_mixed_file_extensions(self):
"""Test loading folder with mixed image extensions."""
with tempfile.TemporaryDirectory() as tmpdir:
folder = Path(tmpdir)
# Create files with different extensions
extensions = ['jpg', 'jpeg', 'JPG', 'JPEG', 'png', 'PNG']
for i, ext in enumerate(extensions):
img_path = folder / f"image_{i}.{ext}"
self._create_test_jpeg(img_path, 100, 100, (i * 40, 100, 100))
# Also create a non-image file that should be ignored
text_file = folder / "readme.txt"
text_file.write_text("This should be ignored")
# Load the folder
self.widget.load_folder(folder)
# Should find all image files (6) but not the text file
self.assertEqual(len(self.widget.image_files), 6)
# Verify text file is not in the list
found_names = [f.name for f in self.widget.image_files]
self.assertNotIn("readme.txt", found_names)
def test_empty_folder(self):
"""Test loading an empty folder."""
with tempfile.TemporaryDirectory() as tmpdir:
folder = Path(tmpdir)
# Load empty folder
self.widget.load_folder(folder)
# Should have no images
self.assertEqual(len(self.widget.image_files), 0)
self.assertEqual(len(self.widget.thumbnails), 0)
self.assertEqual(self.widget.current_folder, folder)
if __name__ == '__main__':
unittest.main()