diff --git a/pyPhotoAlbum/async_backend.py b/pyPhotoAlbum/async_backend.py index 0b38d20..d081982 100644 --- a/pyPhotoAlbum/async_backend.py +++ b/pyPhotoAlbum/async_backend.py @@ -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: diff --git a/pyPhotoAlbum/main.py b/pyPhotoAlbum/main.py index 61989ba..66f69ff 100644 --- a/pyPhotoAlbum/main.py +++ b/pyPhotoAlbum/main.py @@ -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) diff --git a/pyPhotoAlbum/mixins/operations/view_ops.py b/pyPhotoAlbum/mixins/operations/view_ops.py index 1269afc..b9aaf14 100644 --- a/pyPhotoAlbum/mixins/operations/view_ops.py +++ b/pyPhotoAlbum/mixins/operations/view_ops.py @@ -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" ) diff --git a/pyPhotoAlbum/models.py b/pyPhotoAlbum/models.py index 7dbb23f..d942e61 100644 --- a/pyPhotoAlbum/models.py +++ b/pyPhotoAlbum/models.py @@ -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: diff --git a/pyPhotoAlbum/page_layout.py b/pyPhotoAlbum/page_layout.py index 5b6d4ce..1b81d8e 100644 --- a/pyPhotoAlbum/page_layout.py +++ b/pyPhotoAlbum/page_layout.py @@ -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""" diff --git a/pyPhotoAlbum/thumbnail_browser.py b/pyPhotoAlbum/thumbnail_browser.py new file mode 100644 index 0000000..ae70024 --- /dev/null +++ b/pyPhotoAlbum/thumbnail_browser.py @@ -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) diff --git a/tests/test_thumbnail_browser.py b/tests/test_thumbnail_browser.py new file mode 100644 index 0000000..23b9393 --- /dev/null +++ b/tests/test_thumbnail_browser.py @@ -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()