From 5763fa629e5a857d72f7e5f78d4afea7b32c6ee0 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 9 Apr 2026 22:39:15 +0200 Subject: [PATCH] More fixes for CI --- Dockerfile.ci | 1 + pyPhotoAlbum/frame_manager.py | 6 +- pyPhotoAlbum/gl_widget.py | 22 +++++-- pyPhotoAlbum/image_utils.py | 5 +- pyPhotoAlbum/loading_widget.py | 6 +- pyPhotoAlbum/mixins/async_loading.py | 1 + pyPhotoAlbum/mixins/element_selection.py | 1 + pyPhotoAlbum/mixins/mouse_interaction.py | 4 +- pyPhotoAlbum/mixins/operations/file_ops.py | 23 +++---- pyPhotoAlbum/mixins/operations/page_ops.py | 1 - pyPhotoAlbum/mixins/operations/style_ops.py | 1 + pyPhotoAlbum/mixins/operations/view_ops.py | 12 ++-- pyPhotoAlbum/mixins/page_navigation.py | 3 +- pyPhotoAlbum/mixins/rendering.py | 12 ++-- pyPhotoAlbum/models.py | 11 ++-- pyPhotoAlbum/pdf_exporter.py | 23 +++---- pyPhotoAlbum/project_serializer.py | 6 +- pyPhotoAlbum/thumbnail_browser.py | 66 +++++++++------------ pyPhotoAlbum/version_manager.py | 1 - 19 files changed, 107 insertions(+), 98 deletions(-) diff --git a/Dockerfile.ci b/Dockerfile.ci index c8d2113..1f09e51 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -40,4 +40,5 @@ RUN apt-get update && apt-get install -y \ # Misc tools used in workflows curl \ git \ + nodejs \ && rm -rf /var/lib/apt/lists/* diff --git a/pyPhotoAlbum/frame_manager.py b/pyPhotoAlbum/frame_manager.py index 54befdc..6e0812b 100644 --- a/pyPhotoAlbum/frame_manager.py +++ b/pyPhotoAlbum/frame_manager.py @@ -321,6 +321,7 @@ class FrameManager: except Exception as e: import traceback + print(f"Error loading SVG {svg_path}: {e}") traceback.print_exc() return None @@ -404,6 +405,7 @@ class FrameManager: return img except Exception as e: import traceback + print(f"Error getting corner image for {frame.name}: {e}") traceback.print_exc() return None @@ -780,7 +782,9 @@ class FrameManager: # Try SVG rendering for PDF if frame.asset_path and frame.frame_type == FrameType.CORNERS: corner_size_pt = frame_thickness * 2 - if self._render_svg_corners_pdf(canvas, frame, x_pt, y_pt, width_pt, height_pt, corner_size_pt, color, corners): + if self._render_svg_corners_pdf( + canvas, frame, x_pt, y_pt, width_pt, height_pt, corner_size_pt, color, corners + ): canvas.restoreState() return diff --git a/pyPhotoAlbum/gl_widget.py b/pyPhotoAlbum/gl_widget.py index d010737..f431734 100644 --- a/pyPhotoAlbum/gl_widget.py +++ b/pyPhotoAlbum/gl_widget.py @@ -61,6 +61,7 @@ class GLWidget( # Set up OpenGL surface format with explicit double buffering from PyQt6.QtGui import QSurfaceFormat + fmt = QSurfaceFormat() fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer) fmt.setSwapInterval(1) # Enable vsync @@ -89,7 +90,7 @@ class GLWidget( This fixes the Qt widget hierarchy issue where window() returns None because the GL widget is nested in container widgets. """ - return self._main_window if hasattr(self, '_main_window') else super().window() + return self._main_window if hasattr(self, "_main_window") else super().window() def update(self): """Override update to force immediate repaint""" @@ -276,10 +277,18 @@ class GLWidget( self.pan_offset[1] = mouse_y - world_y * self.zoom_level # If dragging, adjust drag_start_pos to account for pan_offset change - if hasattr(self, 'is_dragging') and self.is_dragging and hasattr(self, 'drag_start_pos') and self.drag_start_pos: + if ( + hasattr(self, "is_dragging") + and self.is_dragging + and hasattr(self, "drag_start_pos") + and self.drag_start_pos + ): pan_delta_x = self.pan_offset[0] - old_pan_x pan_delta_y = self.pan_offset[1] - old_pan_y - self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y) + self.drag_start_pos = ( + self.drag_start_pos[0] + pan_delta_x, + self.drag_start_pos[1] + pan_delta_y, + ) # Clamp pan offset to content bounds if hasattr(self, "clamp_pan_offset"): @@ -321,7 +330,12 @@ class GLWidget( self.pan_offset[1] = mouse_y - world_y * self.zoom_level # If dragging, adjust drag_start_pos to account for pan_offset change - if hasattr(self, 'is_dragging') and self.is_dragging and hasattr(self, 'drag_start_pos') and self.drag_start_pos: + if ( + hasattr(self, "is_dragging") + and self.is_dragging + and hasattr(self, "drag_start_pos") + and self.drag_start_pos + ): pan_delta_x = self.pan_offset[0] - old_pan_x pan_delta_y = self.pan_offset[1] - old_pan_y self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y) diff --git a/pyPhotoAlbum/image_utils.py b/pyPhotoAlbum/image_utils.py index ee9c5c2..c8c5278 100644 --- a/pyPhotoAlbum/image_utils.py +++ b/pyPhotoAlbum/image_utils.py @@ -8,7 +8,6 @@ across models.py, pdf_exporter.py, and async_backend.py. from typing import Tuple from PIL import Image - # ============================================================================= # Image Processing Utilities # ============================================================================= @@ -210,9 +209,7 @@ def apply_rounded_corners( mask_large = Image.new("L", (ss_width, ss_height), 0) draw = ImageDraw.Draw(mask_large) - draw.rounded_rectangle( - [0, 0, ss_width - 1, ss_height - 1], radius=ss_radius, fill=255 - ) + draw.rounded_rectangle([0, 0, ss_width - 1, ss_height - 1], radius=ss_radius, fill=255) # Downscale with LANCZOS for smooth antialiased edges mask = mask_large.resize((width, height), Image.Resampling.LANCZOS) diff --git a/pyPhotoAlbum/loading_widget.py b/pyPhotoAlbum/loading_widget.py index 9de1442..e6c3ac2 100644 --- a/pyPhotoAlbum/loading_widget.py +++ b/pyPhotoAlbum/loading_widget.py @@ -29,8 +29,7 @@ class LoadingWidget(QWidget): self.setFixedSize(280, 80) # Styling - self.setStyleSheet( - """ + self.setStyleSheet(""" QWidget { background-color: rgba(50, 50, 50, 230); border-radius: 8px; @@ -55,8 +54,7 @@ class LoadingWidget(QWidget): stop:1 rgba(100, 160, 210, 220)); border-radius: 3px; } - """ - ) + """) # Layout layout = QVBoxLayout() diff --git a/pyPhotoAlbum/mixins/async_loading.py b/pyPhotoAlbum/mixins/async_loading.py index 90c42e8..1f9c02a 100644 --- a/pyPhotoAlbum/mixins/async_loading.py +++ b/pyPhotoAlbum/mixins/async_loading.py @@ -29,6 +29,7 @@ class AsyncLoadingMixin: def window(self) -> "QMainWindow": """Expected from QWidget""" ... + """ Mixin to add async loading capabilities to GLWidget. diff --git a/pyPhotoAlbum/mixins/element_selection.py b/pyPhotoAlbum/mixins/element_selection.py index f0cec68..e5e54ad 100644 --- a/pyPhotoAlbum/mixins/element_selection.py +++ b/pyPhotoAlbum/mixins/element_selection.py @@ -17,6 +17,7 @@ class ElementSelectionMixin: def window(self) -> "QMainWindow": """Expected from QWidget""" ... + """ Mixin providing element selection and hit detection functionality. diff --git a/pyPhotoAlbum/mixins/mouse_interaction.py b/pyPhotoAlbum/mixins/mouse_interaction.py index a47aab4..483d395 100644 --- a/pyPhotoAlbum/mixins/mouse_interaction.py +++ b/pyPhotoAlbum/mixins/mouse_interaction.py @@ -180,9 +180,7 @@ class MouseInteractionMixin: source_page = self.selected_element._parent_page if current_page is not source_page: - self._transfer_element_to_page( - self.selected_element, source_page, current_page, x, y, current_renderer - ) + self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer) else: self._move_element_within_page(x, y, source_page) else: diff --git a/pyPhotoAlbum/mixins/operations/file_ops.py b/pyPhotoAlbum/mixins/operations/file_ops.py index ba5ce9c..310dc38 100644 --- a/pyPhotoAlbum/mixins/operations/file_ops.py +++ b/pyPhotoAlbum/mixins/operations/file_ops.py @@ -39,6 +39,7 @@ class _SaveBridge(QObject): Signals can be safely emitted from any thread; connected slots run on the main (GUI) thread via Qt's queued connection. """ + progress = pyqtSignal(int, str) finished = pyqtSignal(bool, str) @@ -690,7 +691,9 @@ class FileOperationsMixin: else: self.show_status("PDF export failed to start", 3000) - @ribbon_action(label="Clean Assets", tooltip="Find and remove duplicate or unused image files", tab="Home", group="File") + @ribbon_action( + label="Clean Assets", tooltip="Find and remove duplicate or unused image files", tab="Home", group="File" + ) def clean_assets(self): """Find and remove duplicate and unused asset files to save space""" from PyQt6.QtWidgets import QProgressDialog, QCheckBox @@ -731,9 +734,7 @@ class FileOperationsMixin: # Check if there's anything to clean if dup_files == 0 and unused_files == 0: QMessageBox.information( - self, - "Assets Clean", - "No duplicate or unused files were found in your project assets." + self, "Assets Clean", "No duplicate or unused files were found in your project assets." ) return @@ -752,8 +753,7 @@ class FileOperationsMixin: dup_checkbox = None if dup_files > 0: dup_checkbox = QCheckBox( - f"Remove {dup_files} duplicate file(s) in {dup_groups} group(s) " - f"(saves {format_bytes(dup_bytes)})" + f"Remove {dup_files} duplicate file(s) in {dup_groups} group(s) " f"(saves {format_bytes(dup_bytes)})" ) dup_checkbox.setChecked(True) dup_checkbox.setToolTip( @@ -765,9 +765,7 @@ class FileOperationsMixin: # Unused checkbox unused_checkbox = None if unused_files > 0: - unused_checkbox = QCheckBox( - f"Remove {unused_files} unused file(s) (saves {format_bytes(unused_bytes)})" - ) + unused_checkbox = QCheckBox(f"Remove {unused_files} unused file(s) (saves {format_bytes(unused_bytes)})") unused_checkbox.setChecked(True) unused_checkbox.setToolTip( "Unused files exist in the assets folder but are not referenced\n" @@ -806,6 +804,7 @@ class FileOperationsMixin: # Remove duplicates if selected if dup_checkbox and dup_checkbox.isChecked(): + def update_image_references(old_path: str, new_path: str): """Update all ImageData elements that reference the old path""" from pyPhotoAlbum.models import ImageData @@ -842,10 +841,12 @@ class FileOperationsMixin: "Cleanup Complete", f"Removed {total_removed} file(s).\n\n" f"Saved {format_bytes(total_saved)} of disk space.\n\n" - f"Remember to save your project to preserve these changes." + f"Remember to save your project to preserve these changes.", ) - self.show_status(f"Asset cleanup complete: removed {total_removed} files, saved {format_bytes(total_saved)}") + self.show_status( + f"Asset cleanup complete: removed {total_removed} files, saved {format_bytes(total_saved)}" + ) else: self.show_status("No files were removed") diff --git a/pyPhotoAlbum/mixins/operations/page_ops.py b/pyPhotoAlbum/mixins/operations/page_ops.py index 7d8f406..66d478b 100644 --- a/pyPhotoAlbum/mixins/operations/page_ops.py +++ b/pyPhotoAlbum/mixins/operations/page_ops.py @@ -148,7 +148,6 @@ class PageOperationsMixin: self.project.working_dpi = values["working_dpi"] self.project.export_dpi = values["export_dpi"] - # Apply to other pages based on scope # 0 = page only, 1 = non-manual pages, 2 = all pages apply_scope = values.get("apply_scope", 0) diff --git a/pyPhotoAlbum/mixins/operations/style_ops.py b/pyPhotoAlbum/mixins/operations/style_ops.py index be37e60..27783af 100644 --- a/pyPhotoAlbum/mixins/operations/style_ops.py +++ b/pyPhotoAlbum/mixins/operations/style_ops.py @@ -56,6 +56,7 @@ class StyleOperationsMixin: # Delete texture if it exists (will be recreated on next render) if hasattr(img, "_texture_id") and img._texture_id: from pyPhotoAlbum.gl_imports import glDeleteTextures + try: glDeleteTextures([img._texture_id]) except Exception: diff --git a/pyPhotoAlbum/mixins/operations/view_ops.py b/pyPhotoAlbum/mixins/operations/view_ops.py index 0c0c7eb..82f8692 100644 --- a/pyPhotoAlbum/mixins/operations/view_ops.py +++ b/pyPhotoAlbum/mixins/operations/view_ops.py @@ -119,7 +119,9 @@ class ViewOperationsMixin: self.show_status(f"Grid {status}", 2000) print(f"Grid {status}") - @ribbon_action(label="Print Settings...", tooltip="Configure bleed and safe area for all pages", tab="View", group="Guides") + @ribbon_action( + label="Print Settings...", tooltip="Configure bleed and safe area for all pages", tab="View", group="Guides" + ) def open_print_settings(self): """Open the print settings dialog (bleed and safe area)""" if not self.project: @@ -135,7 +137,9 @@ class ViewOperationsMixin: f"Bleed: {values['page_bleed_mm']:.1f}mm, Safe area: {values['page_safe_area_mm']:.1f}mm", 2000 ) - @ribbon_action(label="Print Guides", tooltip="Toggle bleed/cut/safe-area guide lines in the editor", tab="View", group="Guides") + @ribbon_action( + label="Print Guides", tooltip="Toggle bleed/cut/safe-area guide lines in the editor", tab="View", group="Guides" + ) def toggle_print_guides(self): """Toggle print guide lines (bleed/cut/safe area)""" if not self.project: @@ -209,11 +213,11 @@ class ViewOperationsMixin: tooltip="Show/hide the image browser panel", tab="View", group="Panels", - shortcut="Ctrl+B" + shortcut="Ctrl+B", ) def toggle_image_browser(self): """Toggle the thumbnail browser visibility""" - if hasattr(self, '_thumbnail_browser'): + if hasattr(self, "_thumbnail_browser"): if self._thumbnail_browser.isVisible(): self._thumbnail_browser.hide() self.show_status("Image browser hidden", 2000) diff --git a/pyPhotoAlbum/mixins/page_navigation.py b/pyPhotoAlbum/mixins/page_navigation.py index d4b228a..760c448 100644 --- a/pyPhotoAlbum/mixins/page_navigation.py +++ b/pyPhotoAlbum/mixins/page_navigation.py @@ -20,6 +20,7 @@ class PageNavigationMixin: def window(self) -> "QMainWindow": """Expected from QWidget""" ... + """ Mixin providing page navigation and ghost page functionality. @@ -71,7 +72,7 @@ class PageNavigationMixin: List of tuples (page_type, page_or_ghost_data, y_offset) """ # Use stored reference to main window - main_window = getattr(self, '_main_window', None) + main_window = getattr(self, "_main_window", None) if main_window is None: main_window = self.window() diff --git a/pyPhotoAlbum/mixins/rendering.py b/pyPhotoAlbum/mixins/rendering.py index e83dd71..aeddf91 100644 --- a/pyPhotoAlbum/mixins/rendering.py +++ b/pyPhotoAlbum/mixins/rendering.py @@ -26,7 +26,7 @@ class RenderingMixin: glLoadIdentity() # Use stored reference to main window - main_window = getattr(self, '_main_window', None) + main_window = getattr(self, "_main_window", None) if main_window is None: # Fallback to window() if _main_window not set main_window = self.window() @@ -51,7 +51,7 @@ class RenderingMixin: self.initial_zoom_set = True # Update scrollbars now that we have content bounds - if hasattr(self, '_main_window') and hasattr(self._main_window, "update_scrollbars"): + if hasattr(self, "_main_window") and hasattr(self._main_window, "update_scrollbars"): self._main_window.update_scrollbars() dpi = project.working_dpi @@ -385,7 +385,9 @@ class RenderingMixin: sy - bleed_screen, sw + 2 * bleed_screen, sh + 2 * bleed_screen, - 0.0, 0.67, 0.0, + 0.0, + 0.67, + 0.0, dashed=True, ) @@ -400,7 +402,9 @@ class RenderingMixin: sy + safe_screen, sw - 2 * safe_screen, sh - 2 * safe_screen, - 0.8, 0.0, 0.0, + 0.8, + 0.0, + 0.0, ) glColor3f(1.0, 1.0, 1.0) # Reset colour diff --git a/pyPhotoAlbum/models.py b/pyPhotoAlbum/models.py index e8afc56..df7d08b 100644 --- a/pyPhotoAlbum/models.py +++ b/pyPhotoAlbum/models.py @@ -130,12 +130,7 @@ class ImageStyle: def has_styling(self) -> bool: """Check if any styling is applied (non-default values).""" - return ( - self.corner_radius > 0 - or self.border_width > 0 - or self.shadow_enabled - or self.frame_style is not None - ) + return self.corner_radius > 0 or self.border_width > 0 or self.shadow_enabled or self.frame_style is not None def serialize(self) -> Dict[str, Any]: """Serialize style to dictionary.""" @@ -422,7 +417,9 @@ class ImageData(BaseLayoutElement): img_width, img_height = int(w), int(h) # Calculate texture coordinates for center crop with element's crop_info - tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords(img_width, img_height, w, h, self.crop_info) + tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords( + img_width, img_height, w, h, self.crop_info + ) # Enable blending for transparency (rounded corners) glEnable(GL_BLEND) diff --git a/pyPhotoAlbum/pdf_exporter.py b/pyPhotoAlbum/pdf_exporter.py index d033223..1d9b178 100644 --- a/pyPhotoAlbum/pdf_exporter.py +++ b/pyPhotoAlbum/pdf_exporter.py @@ -71,6 +71,7 @@ def _process_image_task(task: ImageTask) -> Tuple[str, Optional[bytes], Optional img = Image.open(task.image_path) except Exception as open_err: import traceback + return (task.task_id, None, f"Image.open failed: {open_err}\n{traceback.format_exc()}") # Now import the rest @@ -84,6 +85,7 @@ def _process_image_task(task: ImageTask) -> Tuple[str, Optional[bytes], Optional ) except Exception as import_err: import traceback + return (task.task_id, None, f"Import image_utils failed: {import_err}\n{traceback.format_exc()}") # Convert to RGBA @@ -136,6 +138,7 @@ def _process_image_task(task: ImageTask) -> Tuple[str, Optional[bytes], Optional except Exception as e: import traceback + return (task.task_id, None, f"{str(e)}\n{traceback.format_exc()}") @@ -246,9 +249,7 @@ class PDFExporter: with ThreadPoolExecutor(max_workers=self.max_workers) as executor: future_to_idx = { - executor.submit( - self._render_item_to_bytes, item, page_width_pt, page_height_pt, bleed_pt - ): i + executor.submit(self._render_item_to_bytes, item, page_width_pt, page_height_pt, bleed_pt): i for i, item in enumerate(page_sequence) } completed = 0 @@ -348,9 +349,7 @@ class PDFExporter: return buf.getvalue() - def _make_blank_page_bytes( - self, page_width_pt: float, page_height_pt: float, bleed_pt: float - ) -> bytes: + def _make_blank_page_bytes(self, page_width_pt: float, page_height_pt: float, bleed_pt: float) -> bytes: """Return a minimal single-blank-page PDF for use as an error placeholder.""" buf = io.BytesIO() c = canvas.Canvas(buf, pagesize=(page_width_pt + 2 * bleed_pt, page_height_pt + 2 * bleed_pt)) @@ -615,7 +614,6 @@ class PDFExporter: for element in sorted(page.layout.elements, key=lambda x: x.z_index): self._render_element(c, element, 0, cover_width_pt, cover_height_pt, "Cover") - c.showPage() # Finish cover page def _export_single_page( @@ -809,7 +807,12 @@ class PDFExporter: # Convert to points (bleed_pt shifts content inside the expanded PDF page) x_pt = adjusted_x_mm * self.MM_TO_POINTS + bleed_pt - y_pt = params.page_height_pt + bleed_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS) + y_pt = ( + params.page_height_pt + + bleed_pt + - (element_y_mm * self.MM_TO_POINTS) + - (element_height_mm * self.MM_TO_POINTS) + ) width_pt = crop_width_mm * self.MM_TO_POINTS height_pt = element_height_mm * self.MM_TO_POINTS @@ -861,9 +864,7 @@ class PDFExporter: ctx: RenderContext containing all rendering parameters """ # Check for pre-processed image in cache - task_id = self._make_task_id( - ctx.image_element, ctx.crop_left, ctx.crop_right, ctx.width_pt, ctx.height_pt - ) + task_id = self._make_task_id(ctx.image_element, ctx.crop_left, ctx.crop_right, ctx.width_pt, ctx.height_pt) cropped_img = self._processed_images.get(task_id) if cropped_img is None: diff --git a/pyPhotoAlbum/project_serializer.py b/pyPhotoAlbum/project_serializer.py index debfbad..6874558 100644 --- a/pyPhotoAlbum/project_serializer.py +++ b/pyPhotoAlbum/project_serializer.py @@ -18,7 +18,6 @@ from pyPhotoAlbum.version_manager import ( DataMigration, ) - # Legacy constant for backward compatibility SERIALIZATION_VERSION = CURRENT_DATA_VERSION @@ -257,10 +256,7 @@ def save_to_zip_async( if idx % 10 == 0 or idx == len(asset_files) - 1: progress = 25 + int((idx + 1) / len(asset_files) * progress_range) if on_progress: - on_progress( - progress, - f"Adding assets... ({idx + 1}/{len(asset_files)})" - ) + on_progress(progress, f"Adding assets... ({idx + 1}/{len(asset_files)})") if on_progress: on_progress(95, "Finalizing save...") diff --git a/pyPhotoAlbum/thumbnail_browser.py b/pyPhotoAlbum/thumbnail_browser.py index 3420aaf..a9a2352 100644 --- a/pyPhotoAlbum/thumbnail_browser.py +++ b/pyPhotoAlbum/thumbnail_browser.py @@ -1,14 +1,12 @@ """ 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, QScrollBar -) +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFileDialog, QDockWidget, QScrollBar from PyQt6.QtCore import Qt, QSize, QMimeData, QUrl, QPoint from PyQt6.QtGui import QDrag, QCursor, QPainter, QFont, QColor from PyQt6.QtOpenGLWidgets import QOpenGLWidget @@ -16,7 +14,6 @@ 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"] @@ -56,8 +53,7 @@ class ThumbnailItem: 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) + return self.x <= x <= self.x + self.thumbnail_size and self.y <= y <= self.y + self.thumbnail_size class ThumbnailGLWidget(QOpenGLWidget): @@ -121,7 +117,7 @@ class ThumbnailGLWidget(QOpenGLWidget): glMatrixMode(GL_MODELVIEW) # Rearrange thumbnails to fit new width - if hasattr(self, 'image_files') and self.image_files: + if hasattr(self, "image_files") and self.image_files: self._arrange_thumbnails() else: # Still update scrollbar even if no thumbnails @@ -186,7 +182,7 @@ class ThumbnailGLWidget(QOpenGLWidget): # 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'): + if hasattr(thumb, "_img_width") and hasattr(thumb, "_img_height"): img_aspect = thumb._img_width / thumb._img_height thumb_aspect = w / h @@ -301,9 +297,7 @@ class ThumbnailGLWidget(QOpenGLWidget): 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 + 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 @@ -340,7 +334,7 @@ class ThumbnailGLWidget(QOpenGLWidget): 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: + if not hasattr(self, "image_files") or not self.image_files: self.thumbnails.clear() return @@ -381,6 +375,7 @@ class ThumbnailGLWidget(QOpenGLWidget): # Check if we need a date header (only in date sort mode) if self.sort_mode == "date" and self._get_image_date_func: from datetime import datetime + timestamp = self._get_image_date_func(image_file) date_obj = datetime.fromtimestamp(timestamp) date_str = date_obj.strftime("%B %d, %Y") # e.g., "December 13, 2025" @@ -503,7 +498,7 @@ class ThumbnailGLWidget(QOpenGLWidget): """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: + if not hasattr(main_window, "project") or not main_window.project: return project = main_window.project @@ -512,6 +507,7 @@ class ThumbnailGLWidget(QOpenGLWidget): 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 @@ -531,11 +527,11 @@ class ThumbnailGLWidget(QOpenGLWidget): # Get main window's async loader main_window = self.window() - if not main_window or not hasattr(main_window, '_gl_widget'): + 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'): + if not hasattr(gl_widget, "async_image_loader"): return from pyPhotoAlbum.async_backend import LoadPriority @@ -550,7 +546,7 @@ class ThumbnailGLWidget(QOpenGLWidget): Path(thumb.image_path), priority=LoadPriority.LOW, target_size=(200, 200), # Small thumbnails - user_data=thumb + user_data=thumb, ) except RuntimeError: thumb._async_loading = False # Reset on error @@ -623,10 +619,7 @@ class ThumbnailGLWidget(QOpenGLWidget): # 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.pan_offset = (0, self.pan_offset[1] + delta.y()) # No horizontal pan - grid is centered self.drag_start_pos = event.pos() self._update_scrollbar_position() self.update() @@ -664,15 +657,12 @@ class ThumbnailGLWidget(QOpenGLWidget): # 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 + 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.pan_offset = (0, self.pan_offset[1] + scroll_amount) # No horizontal pan self._update_scrollbar_position() self.update() @@ -757,11 +747,12 @@ class ThumbnailBrowserDock(QDockWidget): 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) + 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() @@ -769,15 +760,15 @@ class ThumbnailBrowserDock(QDockWidget): def _connect_async_loader(self): """Connect to main window's async image loader.""" main_window = self.window() - if not hasattr(main_window, '_gl_widget'): + if not hasattr(main_window, "_gl_widget"): return gl_widget = main_window._gl_widget - if not hasattr(gl_widget, 'async_image_loader'): + if not hasattr(gl_widget, "async_image_loader"): return # Avoid duplicate connections - if hasattr(self, '_async_connected') and self._async_connected: + if hasattr(self, "_async_connected") and self._async_connected: return try: @@ -800,7 +791,7 @@ class ThumbnailBrowserDock(QDockWidget): self, "Select Image Folder", str(self.gl_widget.current_folder) if self.gl_widget.current_folder else str(Path.home()), - QFileDialog.Option.ShowDirsOnly + QFileDialog.Option.ShowDirsOnly, ) if folder_path: @@ -826,7 +817,7 @@ class ThumbnailBrowserDock(QDockWidget): self.current_sort = sort_mode # Re-sort the image files in the GL widget - if hasattr(self.gl_widget, 'image_files') and self.gl_widget.image_files: + if hasattr(self.gl_widget, "image_files") and self.gl_widget.image_files: self._apply_sort() # Re-arrange thumbnails with new order self.gl_widget._arrange_thumbnails() @@ -835,7 +826,7 @@ class ThumbnailBrowserDock(QDockWidget): def _apply_sort(self): """Apply current sort mode to image files.""" - if not hasattr(self.gl_widget, 'image_files') or not self.gl_widget.image_files: + if not hasattr(self.gl_widget, "image_files") or not self.gl_widget.image_files: return if self.current_sort == "name": # Sort by filename only (not full path) @@ -875,6 +866,7 @@ class ThumbnailBrowserDock(QDockWidget): if tag == "DateTimeOriginal": # Convert EXIF date format "2023:12:13 14:30:00" to timestamp from datetime import datetime + try: dt = datetime.strptime(value, "%Y:%m:%d %H:%M:%S") return dt.timestamp() diff --git a/pyPhotoAlbum/version_manager.py b/pyPhotoAlbum/version_manager.py index 9062d84..6f23ea7 100644 --- a/pyPhotoAlbum/version_manager.py +++ b/pyPhotoAlbum/version_manager.py @@ -7,7 +7,6 @@ import uuid from datetime import datetime, timezone from typing import Dict, Any, Optional, Callable, List - # Current data version - increment when making breaking changes to data format CURRENT_DATA_VERSION = "3.0"