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
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:
parent
3092388226
commit
5763fa629e
@ -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/*
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -29,6 +29,7 @@ class AsyncLoadingMixin:
|
||||
def window(self) -> "QMainWindow":
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
|
||||
"""
|
||||
Mixin to add async loading capabilities to GLWidget.
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ class ElementSelectionMixin:
|
||||
def window(self) -> "QMainWindow":
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
|
||||
"""
|
||||
Mixin providing element selection and hit detection functionality.
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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...")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user