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
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:
parent
c66724c190
commit
8f9f387848
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"""
|
||||
|
||||
575
pyPhotoAlbum/thumbnail_browser.py
Normal file
575
pyPhotoAlbum/thumbnail_browser.py
Normal 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)
|
||||
473
tests/test_thumbnail_browser.py
Normal file
473
tests/test_thumbnail_browser.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user