More fixes for CI
Some checks failed
Lint / lint (push) Successful in 20s
Python CI / test (push) Failing after 1m42s
Tests / test (3.12) (push) Failing after 6s
Tests / test (3.13) (push) Failing after 6s
Tests / test (3.14) (push) Failing after 8s
Tests / test (3.11) (push) Failing after 37s

This commit is contained in:
Duncan Tourolle 2026-04-09 22:39:15 +02:00
parent 3092388226
commit 5763fa629e
19 changed files with 107 additions and 98 deletions

View File

@ -40,4 +40,5 @@ RUN apt-get update && apt-get install -y \
# Misc tools used in workflows # Misc tools used in workflows
curl \ curl \
git \ git \
nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

View File

@ -321,6 +321,7 @@ class FrameManager:
except Exception as e: except Exception as e:
import traceback import traceback
print(f"Error loading SVG {svg_path}: {e}") print(f"Error loading SVG {svg_path}: {e}")
traceback.print_exc() traceback.print_exc()
return None return None
@ -404,6 +405,7 @@ class FrameManager:
return img return img
except Exception as e: except Exception as e:
import traceback import traceback
print(f"Error getting corner image for {frame.name}: {e}") print(f"Error getting corner image for {frame.name}: {e}")
traceback.print_exc() traceback.print_exc()
return None return None
@ -780,7 +782,9 @@ class FrameManager:
# Try SVG rendering for PDF # Try SVG rendering for PDF
if frame.asset_path and frame.frame_type == FrameType.CORNERS: if frame.asset_path and frame.frame_type == FrameType.CORNERS:
corner_size_pt = frame_thickness * 2 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() canvas.restoreState()
return return

View File

@ -61,6 +61,7 @@ class GLWidget(
# Set up OpenGL surface format with explicit double buffering # Set up OpenGL surface format with explicit double buffering
from PyQt6.QtGui import QSurfaceFormat from PyQt6.QtGui import QSurfaceFormat
fmt = QSurfaceFormat() fmt = QSurfaceFormat()
fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer) fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer)
fmt.setSwapInterval(1) # Enable vsync fmt.setSwapInterval(1) # Enable vsync
@ -89,7 +90,7 @@ class GLWidget(
This fixes the Qt widget hierarchy issue where window() returns None This fixes the Qt widget hierarchy issue where window() returns None
because the GL widget is nested in container widgets. 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): def update(self):
"""Override update to force immediate repaint""" """Override update to force immediate repaint"""
@ -276,10 +277,18 @@ class GLWidget(
self.pan_offset[1] = mouse_y - world_y * self.zoom_level self.pan_offset[1] = mouse_y - world_y * self.zoom_level
# If dragging, adjust drag_start_pos to account for pan_offset change # 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_x = self.pan_offset[0] - old_pan_x
pan_delta_y = self.pan_offset[1] - old_pan_y 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 # Clamp pan offset to content bounds
if hasattr(self, "clamp_pan_offset"): if hasattr(self, "clamp_pan_offset"):
@ -321,7 +330,12 @@ class GLWidget(
self.pan_offset[1] = mouse_y - world_y * self.zoom_level self.pan_offset[1] = mouse_y - world_y * self.zoom_level
# If dragging, adjust drag_start_pos to account for pan_offset change # 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_x = self.pan_offset[0] - old_pan_x
pan_delta_y = self.pan_offset[1] - old_pan_y 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)

View File

@ -8,7 +8,6 @@ across models.py, pdf_exporter.py, and async_backend.py.
from typing import Tuple from typing import Tuple
from PIL import Image from PIL import Image
# ============================================================================= # =============================================================================
# Image Processing Utilities # Image Processing Utilities
# ============================================================================= # =============================================================================
@ -210,9 +209,7 @@ def apply_rounded_corners(
mask_large = Image.new("L", (ss_width, ss_height), 0) mask_large = Image.new("L", (ss_width, ss_height), 0)
draw = ImageDraw.Draw(mask_large) draw = ImageDraw.Draw(mask_large)
draw.rounded_rectangle( draw.rounded_rectangle([0, 0, ss_width - 1, ss_height - 1], radius=ss_radius, fill=255)
[0, 0, ss_width - 1, ss_height - 1], radius=ss_radius, fill=255
)
# Downscale with LANCZOS for smooth antialiased edges # Downscale with LANCZOS for smooth antialiased edges
mask = mask_large.resize((width, height), Image.Resampling.LANCZOS) mask = mask_large.resize((width, height), Image.Resampling.LANCZOS)

View File

@ -29,8 +29,7 @@ class LoadingWidget(QWidget):
self.setFixedSize(280, 80) self.setFixedSize(280, 80)
# Styling # Styling
self.setStyleSheet( self.setStyleSheet("""
"""
QWidget { QWidget {
background-color: rgba(50, 50, 50, 230); background-color: rgba(50, 50, 50, 230);
border-radius: 8px; border-radius: 8px;
@ -55,8 +54,7 @@ class LoadingWidget(QWidget):
stop:1 rgba(100, 160, 210, 220)); stop:1 rgba(100, 160, 210, 220));
border-radius: 3px; border-radius: 3px;
} }
""" """)
)
# Layout # Layout
layout = QVBoxLayout() layout = QVBoxLayout()

View File

@ -29,6 +29,7 @@ class AsyncLoadingMixin:
def window(self) -> "QMainWindow": def window(self) -> "QMainWindow":
"""Expected from QWidget""" """Expected from QWidget"""
... ...
""" """
Mixin to add async loading capabilities to GLWidget. Mixin to add async loading capabilities to GLWidget.

View File

@ -17,6 +17,7 @@ class ElementSelectionMixin:
def window(self) -> "QMainWindow": def window(self) -> "QMainWindow":
"""Expected from QWidget""" """Expected from QWidget"""
... ...
""" """
Mixin providing element selection and hit detection functionality. Mixin providing element selection and hit detection functionality.

View File

@ -180,9 +180,7 @@ class MouseInteractionMixin:
source_page = self.selected_element._parent_page source_page = self.selected_element._parent_page
if current_page is not source_page: if current_page is not source_page:
self._transfer_element_to_page( self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer)
self.selected_element, source_page, current_page, x, y, current_renderer
)
else: else:
self._move_element_within_page(x, y, source_page) self._move_element_within_page(x, y, source_page)
else: else:

View File

@ -39,6 +39,7 @@ class _SaveBridge(QObject):
Signals can be safely emitted from any thread; connected slots run on Signals can be safely emitted from any thread; connected slots run on
the main (GUI) thread via Qt's queued connection. the main (GUI) thread via Qt's queued connection.
""" """
progress = pyqtSignal(int, str) progress = pyqtSignal(int, str)
finished = pyqtSignal(bool, str) finished = pyqtSignal(bool, str)
@ -690,7 +691,9 @@ class FileOperationsMixin:
else: else:
self.show_status("PDF export failed to start", 3000) 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): def clean_assets(self):
"""Find and remove duplicate and unused asset files to save space""" """Find and remove duplicate and unused asset files to save space"""
from PyQt6.QtWidgets import QProgressDialog, QCheckBox from PyQt6.QtWidgets import QProgressDialog, QCheckBox
@ -731,9 +734,7 @@ class FileOperationsMixin:
# Check if there's anything to clean # Check if there's anything to clean
if dup_files == 0 and unused_files == 0: if dup_files == 0 and unused_files == 0:
QMessageBox.information( QMessageBox.information(
self, self, "Assets Clean", "No duplicate or unused files were found in your project assets."
"Assets Clean",
"No duplicate or unused files were found in your project assets."
) )
return return
@ -752,8 +753,7 @@ class FileOperationsMixin:
dup_checkbox = None dup_checkbox = None
if dup_files > 0: if dup_files > 0:
dup_checkbox = QCheckBox( dup_checkbox = QCheckBox(
f"Remove {dup_files} duplicate file(s) in {dup_groups} group(s) " f"Remove {dup_files} duplicate file(s) in {dup_groups} group(s) " f"(saves {format_bytes(dup_bytes)})"
f"(saves {format_bytes(dup_bytes)})"
) )
dup_checkbox.setChecked(True) dup_checkbox.setChecked(True)
dup_checkbox.setToolTip( dup_checkbox.setToolTip(
@ -765,9 +765,7 @@ class FileOperationsMixin:
# Unused checkbox # Unused checkbox
unused_checkbox = None unused_checkbox = None
if unused_files > 0: if unused_files > 0:
unused_checkbox = QCheckBox( unused_checkbox = QCheckBox(f"Remove {unused_files} unused file(s) (saves {format_bytes(unused_bytes)})")
f"Remove {unused_files} unused file(s) (saves {format_bytes(unused_bytes)})"
)
unused_checkbox.setChecked(True) unused_checkbox.setChecked(True)
unused_checkbox.setToolTip( unused_checkbox.setToolTip(
"Unused files exist in the assets folder but are not referenced\n" "Unused files exist in the assets folder but are not referenced\n"
@ -806,6 +804,7 @@ class FileOperationsMixin:
# Remove duplicates if selected # Remove duplicates if selected
if dup_checkbox and dup_checkbox.isChecked(): if dup_checkbox and dup_checkbox.isChecked():
def update_image_references(old_path: str, new_path: str): def update_image_references(old_path: str, new_path: str):
"""Update all ImageData elements that reference the old path""" """Update all ImageData elements that reference the old path"""
from pyPhotoAlbum.models import ImageData from pyPhotoAlbum.models import ImageData
@ -842,10 +841,12 @@ class FileOperationsMixin:
"Cleanup Complete", "Cleanup Complete",
f"Removed {total_removed} file(s).\n\n" f"Removed {total_removed} file(s).\n\n"
f"Saved {format_bytes(total_saved)} of disk space.\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: else:
self.show_status("No files were removed") self.show_status("No files were removed")

View File

@ -148,7 +148,6 @@ class PageOperationsMixin:
self.project.working_dpi = values["working_dpi"] self.project.working_dpi = values["working_dpi"]
self.project.export_dpi = values["export_dpi"] self.project.export_dpi = values["export_dpi"]
# Apply to other pages based on scope # Apply to other pages based on scope
# 0 = page only, 1 = non-manual pages, 2 = all pages # 0 = page only, 1 = non-manual pages, 2 = all pages
apply_scope = values.get("apply_scope", 0) apply_scope = values.get("apply_scope", 0)

View File

@ -56,6 +56,7 @@ class StyleOperationsMixin:
# Delete texture if it exists (will be recreated on next render) # Delete texture if it exists (will be recreated on next render)
if hasattr(img, "_texture_id") and img._texture_id: if hasattr(img, "_texture_id") and img._texture_id:
from pyPhotoAlbum.gl_imports import glDeleteTextures from pyPhotoAlbum.gl_imports import glDeleteTextures
try: try:
glDeleteTextures([img._texture_id]) glDeleteTextures([img._texture_id])
except Exception: except Exception:

View File

@ -119,7 +119,9 @@ class ViewOperationsMixin:
self.show_status(f"Grid {status}", 2000) self.show_status(f"Grid {status}", 2000)
print(f"Grid {status}") 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): def open_print_settings(self):
"""Open the print settings dialog (bleed and safe area)""" """Open the print settings dialog (bleed and safe area)"""
if not self.project: 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 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): def toggle_print_guides(self):
"""Toggle print guide lines (bleed/cut/safe area)""" """Toggle print guide lines (bleed/cut/safe area)"""
if not self.project: if not self.project:
@ -209,11 +213,11 @@ class ViewOperationsMixin:
tooltip="Show/hide the image browser panel", tooltip="Show/hide the image browser panel",
tab="View", tab="View",
group="Panels", group="Panels",
shortcut="Ctrl+B" shortcut="Ctrl+B",
) )
def toggle_image_browser(self): def toggle_image_browser(self):
"""Toggle the thumbnail browser visibility""" """Toggle the thumbnail browser visibility"""
if hasattr(self, '_thumbnail_browser'): if hasattr(self, "_thumbnail_browser"):
if self._thumbnail_browser.isVisible(): if self._thumbnail_browser.isVisible():
self._thumbnail_browser.hide() self._thumbnail_browser.hide()
self.show_status("Image browser hidden", 2000) self.show_status("Image browser hidden", 2000)

View File

@ -20,6 +20,7 @@ class PageNavigationMixin:
def window(self) -> "QMainWindow": def window(self) -> "QMainWindow":
"""Expected from QWidget""" """Expected from QWidget"""
... ...
""" """
Mixin providing page navigation and ghost page functionality. 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) List of tuples (page_type, page_or_ghost_data, y_offset)
""" """
# Use stored reference to main window # 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: if main_window is None:
main_window = self.window() main_window = self.window()

View File

@ -26,7 +26,7 @@ class RenderingMixin:
glLoadIdentity() glLoadIdentity()
# Use stored reference to main window # 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: if main_window is None:
# Fallback to window() if _main_window not set # Fallback to window() if _main_window not set
main_window = self.window() main_window = self.window()
@ -51,7 +51,7 @@ class RenderingMixin:
self.initial_zoom_set = True self.initial_zoom_set = True
# Update scrollbars now that we have content bounds # 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() self._main_window.update_scrollbars()
dpi = project.working_dpi dpi = project.working_dpi
@ -385,7 +385,9 @@ class RenderingMixin:
sy - bleed_screen, sy - bleed_screen,
sw + 2 * bleed_screen, sw + 2 * bleed_screen,
sh + 2 * bleed_screen, sh + 2 * bleed_screen,
0.0, 0.67, 0.0, 0.0,
0.67,
0.0,
dashed=True, dashed=True,
) )
@ -400,7 +402,9 @@ class RenderingMixin:
sy + safe_screen, sy + safe_screen,
sw - 2 * safe_screen, sw - 2 * safe_screen,
sh - 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 glColor3f(1.0, 1.0, 1.0) # Reset colour

View File

@ -130,12 +130,7 @@ class ImageStyle:
def has_styling(self) -> bool: def has_styling(self) -> bool:
"""Check if any styling is applied (non-default values).""" """Check if any styling is applied (non-default values)."""
return ( return self.corner_radius > 0 or self.border_width > 0 or self.shadow_enabled or self.frame_style is not None
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]: def serialize(self) -> Dict[str, Any]:
"""Serialize style to dictionary.""" """Serialize style to dictionary."""
@ -422,7 +417,9 @@ class ImageData(BaseLayoutElement):
img_width, img_height = int(w), int(h) img_width, img_height = int(w), int(h)
# Calculate texture coordinates for center crop with element's crop_info # 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) # Enable blending for transparency (rounded corners)
glEnable(GL_BLEND) glEnable(GL_BLEND)

View File

@ -71,6 +71,7 @@ def _process_image_task(task: ImageTask) -> Tuple[str, Optional[bytes], Optional
img = Image.open(task.image_path) img = Image.open(task.image_path)
except Exception as open_err: except Exception as open_err:
import traceback import traceback
return (task.task_id, None, f"Image.open failed: {open_err}\n{traceback.format_exc()}") return (task.task_id, None, f"Image.open failed: {open_err}\n{traceback.format_exc()}")
# Now import the rest # Now import the rest
@ -84,6 +85,7 @@ def _process_image_task(task: ImageTask) -> Tuple[str, Optional[bytes], Optional
) )
except Exception as import_err: except Exception as import_err:
import traceback import traceback
return (task.task_id, None, f"Import image_utils failed: {import_err}\n{traceback.format_exc()}") return (task.task_id, None, f"Import image_utils failed: {import_err}\n{traceback.format_exc()}")
# Convert to RGBA # Convert to RGBA
@ -136,6 +138,7 @@ def _process_image_task(task: ImageTask) -> Tuple[str, Optional[bytes], Optional
except Exception as e: except Exception as e:
import traceback import traceback
return (task.task_id, None, f"{str(e)}\n{traceback.format_exc()}") 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: with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
future_to_idx = { future_to_idx = {
executor.submit( executor.submit(self._render_item_to_bytes, item, page_width_pt, page_height_pt, bleed_pt): i
self._render_item_to_bytes, item, page_width_pt, page_height_pt, bleed_pt
): i
for i, item in enumerate(page_sequence) for i, item in enumerate(page_sequence)
} }
completed = 0 completed = 0
@ -348,9 +349,7 @@ class PDFExporter:
return buf.getvalue() return buf.getvalue()
def _make_blank_page_bytes( def _make_blank_page_bytes(self, page_width_pt: float, page_height_pt: float, bleed_pt: float) -> 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.""" """Return a minimal single-blank-page PDF for use as an error placeholder."""
buf = io.BytesIO() buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=(page_width_pt + 2 * bleed_pt, page_height_pt + 2 * bleed_pt)) 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): 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") self._render_element(c, element, 0, cover_width_pt, cover_height_pt, "Cover")
c.showPage() # Finish cover page c.showPage() # Finish cover page
def _export_single_page( def _export_single_page(
@ -809,7 +807,12 @@ class PDFExporter:
# Convert to points (bleed_pt shifts content inside the expanded PDF page) # Convert to points (bleed_pt shifts content inside the expanded PDF page)
x_pt = adjusted_x_mm * self.MM_TO_POINTS + bleed_pt 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 width_pt = crop_width_mm * self.MM_TO_POINTS
height_pt = element_height_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 ctx: RenderContext containing all rendering parameters
""" """
# Check for pre-processed image in cache # Check for pre-processed image in cache
task_id = self._make_task_id( task_id = self._make_task_id(ctx.image_element, ctx.crop_left, ctx.crop_right, ctx.width_pt, ctx.height_pt)
ctx.image_element, ctx.crop_left, ctx.crop_right, ctx.width_pt, ctx.height_pt
)
cropped_img = self._processed_images.get(task_id) cropped_img = self._processed_images.get(task_id)
if cropped_img is None: if cropped_img is None:

View File

@ -18,7 +18,6 @@ from pyPhotoAlbum.version_manager import (
DataMigration, DataMigration,
) )
# Legacy constant for backward compatibility # Legacy constant for backward compatibility
SERIALIZATION_VERSION = CURRENT_DATA_VERSION SERIALIZATION_VERSION = CURRENT_DATA_VERSION
@ -257,10 +256,7 @@ def save_to_zip_async(
if idx % 10 == 0 or idx == len(asset_files) - 1: if idx % 10 == 0 or idx == len(asset_files) - 1:
progress = 25 + int((idx + 1) / len(asset_files) * progress_range) progress = 25 + int((idx + 1) / len(asset_files) * progress_range)
if on_progress: if on_progress:
on_progress( on_progress(progress, f"Adding assets... ({idx + 1}/{len(asset_files)})")
progress,
f"Adding assets... ({idx + 1}/{len(asset_files)})"
)
if on_progress: if on_progress:
on_progress(95, "Finalizing save...") on_progress(95, "Finalizing save...")

View File

@ -1,14 +1,12 @@
""" """
Thumbnail Browser Widget - displays thumbnails from a folder for drag-and-drop into album. Thumbnail Browser Widget - displays thumbnails from a folder for drag-and-drop into album.
""" """
import os import os
from pathlib import Path from pathlib import Path
from typing import Optional, List, Tuple from typing import Optional, List, Tuple
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFileDialog, QDockWidget, QScrollBar
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QFileDialog, QDockWidget, QScrollBar
)
from PyQt6.QtCore import Qt, QSize, QMimeData, QUrl, QPoint from PyQt6.QtCore import Qt, QSize, QMimeData, QUrl, QPoint
from PyQt6.QtGui import QDrag, QCursor, QPainter, QFont, QColor from PyQt6.QtGui import QDrag, QCursor, QPainter, QFont, QColor
from PyQt6.QtOpenGLWidgets import QOpenGLWidget from PyQt6.QtOpenGLWidgets import QOpenGLWidget
@ -16,7 +14,6 @@ from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.gl_imports import * from pyPhotoAlbum.gl_imports import *
from pyPhotoAlbum.mixins.viewport import ViewportMixin from pyPhotoAlbum.mixins.viewport import ViewportMixin
IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"] IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"]
@ -56,8 +53,7 @@ class ThumbnailItem:
def contains_point(self, x: float, y: float) -> bool: def contains_point(self, x: float, y: float) -> bool:
"""Check if point is inside this thumbnail.""" """Check if point is inside this thumbnail."""
return (self.x <= x <= self.x + self.thumbnail_size and return self.x <= x <= self.x + self.thumbnail_size and self.y <= y <= self.y + self.thumbnail_size
self.y <= y <= self.y + self.thumbnail_size)
class ThumbnailGLWidget(QOpenGLWidget): class ThumbnailGLWidget(QOpenGLWidget):
@ -121,7 +117,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
glMatrixMode(GL_MODELVIEW) glMatrixMode(GL_MODELVIEW)
# Rearrange thumbnails to fit new width # 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() self._arrange_thumbnails()
else: else:
# Still update scrollbar even if no thumbnails # Still update scrollbar even if no thumbnails
@ -186,7 +182,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
# Render based on state: texture, loading placeholder, or empty placeholder # Render based on state: texture, loading placeholder, or empty placeholder
if thumb._texture_id: if thumb._texture_id:
# Calculate aspect-ratio-corrected dimensions # 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 img_aspect = thumb._img_width / thumb._img_height
thumb_aspect = w / h 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_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexImage2D( glTexImage2D(
GL_TEXTURE_2D, 0, GL_RGBA, GL_TEXTURE_2D, 0, GL_RGBA, pil_image.width, pil_image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data
pil_image.width, pil_image.height,
0, GL_RGBA, GL_UNSIGNED_BYTE, img_data
) )
thumb._texture_id = texture_id thumb._texture_id = texture_id
@ -340,7 +334,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
def _arrange_thumbnails(self): def _arrange_thumbnails(self):
"""Arrange thumbnails in a grid based on widget width and zoom level.""" """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() self.thumbnails.clear()
return return
@ -381,6 +375,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
# Check if we need a date header (only in date sort mode) # Check if we need a date header (only in date sort mode)
if self.sort_mode == "date" and self._get_image_date_func: if self.sort_mode == "date" and self._get_image_date_func:
from datetime import datetime from datetime import datetime
timestamp = self._get_image_date_func(image_file) timestamp = self._get_image_date_func(image_file)
date_obj = datetime.fromtimestamp(timestamp) date_obj = datetime.fromtimestamp(timestamp)
date_str = date_obj.strftime("%B %d, %Y") # e.g., "December 13, 2025" 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.""" """Update which thumbnails are already used in the project."""
# Get reference to main window's project # Get reference to main window's project
main_window = self.window() 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 return
project = main_window.project project = main_window.project
@ -512,6 +507,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
used_paths = set() used_paths = set()
for page in project.pages: for page in project.pages:
from pyPhotoAlbum.models import ImageData from pyPhotoAlbum.models import ImageData
for element in page.layout.elements: for element in page.layout.elements:
if isinstance(element, ImageData) and element.image_path: if isinstance(element, ImageData) and element.image_path:
# Resolve to absolute path for comparison # Resolve to absolute path for comparison
@ -531,11 +527,11 @@ class ThumbnailGLWidget(QOpenGLWidget):
# Get main window's async loader # Get main window's async loader
main_window = self.window() 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 return
gl_widget = main_window._gl_widget gl_widget = main_window._gl_widget
if not hasattr(gl_widget, 'async_image_loader'): if not hasattr(gl_widget, "async_image_loader"):
return return
from pyPhotoAlbum.async_backend import LoadPriority from pyPhotoAlbum.async_backend import LoadPriority
@ -550,7 +546,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
Path(thumb.image_path), Path(thumb.image_path),
priority=LoadPriority.LOW, priority=LoadPriority.LOW,
target_size=(200, 200), # Small thumbnails target_size=(200, 200), # Small thumbnails
user_data=thumb user_data=thumb,
) )
except RuntimeError: except RuntimeError:
thumb._async_loading = False # Reset on error thumb._async_loading = False # Reset on error
@ -623,10 +619,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
# Pan the view (right-click or middle-click drag) # Pan the view (right-click or middle-click drag)
# Only allow vertical panning - grid is always horizontally centered # Only allow vertical panning - grid is always horizontally centered
delta = event.pos() - self.drag_start_pos delta = event.pos() - self.drag_start_pos
self.pan_offset = ( self.pan_offset = (0, self.pan_offset[1] + delta.y()) # No horizontal pan - grid is centered
0, # No horizontal pan - grid is centered
self.pan_offset[1] + delta.y()
)
self.drag_start_pos = event.pos() self.drag_start_pos = event.pos()
self._update_scrollbar_position() self._update_scrollbar_position()
self.update() self.update()
@ -664,15 +657,12 @@ class ThumbnailGLWidget(QOpenGLWidget):
# Keep horizontal pan at 0 (grid is always horizontally centered) # Keep horizontal pan at 0 (grid is always horizontally centered)
self.pan_offset = ( self.pan_offset = (
0, # No horizontal pan - grid is centered in _arrange_thumbnails 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: else:
# Scroll mode - scroll vertically only # Scroll mode - scroll vertically only
scroll_amount = delta * 0.5 # Adjust sensitivity scroll_amount = delta * 0.5 # Adjust sensitivity
self.pan_offset = ( self.pan_offset = (0, self.pan_offset[1] + scroll_amount) # No horizontal pan
0, # No horizontal pan
self.pan_offset[1] + scroll_amount
)
self._update_scrollbar_position() self._update_scrollbar_position()
self.update() self.update()
@ -757,11 +747,12 @@ class ThumbnailBrowserDock(QDockWidget):
self.setWidget(main_widget) self.setWidget(main_widget)
# Dock settings # Dock settings
self.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea | self.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea)
Qt.DockWidgetArea.RightDockWidgetArea) self.setFeatures(
self.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable | QDockWidget.DockWidgetFeature.DockWidgetClosable
QDockWidget.DockWidgetFeature.DockWidgetMovable | | QDockWidget.DockWidgetFeature.DockWidgetMovable
QDockWidget.DockWidgetFeature.DockWidgetFloatable) | QDockWidget.DockWidgetFeature.DockWidgetFloatable
)
# Connect to main window's async loader when shown # Connect to main window's async loader when shown
self._connect_async_loader() self._connect_async_loader()
@ -769,15 +760,15 @@ class ThumbnailBrowserDock(QDockWidget):
def _connect_async_loader(self): def _connect_async_loader(self):
"""Connect to main window's async image loader.""" """Connect to main window's async image loader."""
main_window = self.window() main_window = self.window()
if not hasattr(main_window, '_gl_widget'): if not hasattr(main_window, "_gl_widget"):
return return
gl_widget = main_window._gl_widget gl_widget = main_window._gl_widget
if not hasattr(gl_widget, 'async_image_loader'): if not hasattr(gl_widget, "async_image_loader"):
return return
# Avoid duplicate connections # Avoid duplicate connections
if hasattr(self, '_async_connected') and self._async_connected: if hasattr(self, "_async_connected") and self._async_connected:
return return
try: try:
@ -800,7 +791,7 @@ class ThumbnailBrowserDock(QDockWidget):
self, self,
"Select Image Folder", "Select Image Folder",
str(self.gl_widget.current_folder) if self.gl_widget.current_folder else str(Path.home()), 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: if folder_path:
@ -826,7 +817,7 @@ class ThumbnailBrowserDock(QDockWidget):
self.current_sort = sort_mode self.current_sort = sort_mode
# Re-sort the image files in the GL widget # 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() self._apply_sort()
# Re-arrange thumbnails with new order # Re-arrange thumbnails with new order
self.gl_widget._arrange_thumbnails() self.gl_widget._arrange_thumbnails()
@ -835,7 +826,7 @@ class ThumbnailBrowserDock(QDockWidget):
def _apply_sort(self): def _apply_sort(self):
"""Apply current sort mode to image files.""" """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 return
if self.current_sort == "name": if self.current_sort == "name":
# Sort by filename only (not full path) # Sort by filename only (not full path)
@ -875,6 +866,7 @@ class ThumbnailBrowserDock(QDockWidget):
if tag == "DateTimeOriginal": if tag == "DateTimeOriginal":
# Convert EXIF date format "2023:12:13 14:30:00" to timestamp # Convert EXIF date format "2023:12:13 14:30:00" to timestamp
from datetime import datetime from datetime import datetime
try: try:
dt = datetime.strptime(value, "%Y:%m:%d %H:%M:%S") dt = datetime.strptime(value, "%Y:%m:%d %H:%M:%S")
return dt.timestamp() return dt.timestamp()

View File

@ -7,7 +7,6 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, Any, Optional, Callable, List from typing import Dict, Any, Optional, Callable, List
# Current data version - increment when making breaking changes to data format # Current data version - increment when making breaking changes to data format
CURRENT_DATA_VERSION = "3.0" CURRENT_DATA_VERSION = "3.0"