Sort library tab, background saving
All checks were successful
Python CI / test (push) Successful in 1m26s
Lint / lint (push) Successful in 1m13s
Tests / test (3.11) (push) Successful in 1m40s
Tests / test (3.12) (push) Successful in 1m46s
Tests / test (3.13) (push) Successful in 1m36s
Tests / test (3.14) (push) Successful in 1m13s

This commit is contained in:
Duncan Tourolle 2025-12-13 16:53:43 +01:00
parent 8f9f387848
commit c1ee894e7b
3 changed files with 537 additions and 28 deletions

View File

@ -26,7 +26,7 @@ from pyPhotoAlbum.decorators import ribbon_action, numerical_input
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.async_project_loader import AsyncProjectLoader
from pyPhotoAlbum.loading_widget import LoadingWidget
from pyPhotoAlbum.project_serializer import save_to_zip
from pyPhotoAlbum.project_serializer import save_to_zip, save_to_zip_async
from pyPhotoAlbum.models import set_asset_resolution_context
from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
@ -275,7 +275,7 @@ class FileOperationsMixin:
@ribbon_action(label="Save", tooltip="Save the current project", tab="Home", group="File", shortcut="Ctrl+S")
def save_project(self):
"""Save the current project"""
"""Save the current project asynchronously with progress feedback"""
# If project has a file path, use it; otherwise prompt for location
file_path = self.project.file_path if hasattr(self.project, "file_path") and self.project.file_path else None
@ -287,18 +287,47 @@ class FileOperationsMixin:
if file_path:
print(f"Saving project to: {file_path}")
# Save project to ZIP
success, error = save_to_zip(self.project, file_path)
# Create loading widget if not exists
if not hasattr(self, "_loading_widget"):
self._loading_widget = LoadingWidget(self)
if success:
self.project.file_path = file_path
self.project.mark_clean()
self.show_status(f"Project saved: {file_path}")
print(f"Successfully saved project to: {file_path}")
else:
error_msg = f"Failed to save project: {error}"
self.show_status(error_msg)
print(error_msg)
# Show loading widget
self._loading_widget.show_loading("Saving project...")
# Define callbacks for async save
def on_progress(progress: int, message: str):
"""Update progress display"""
if hasattr(self, "_loading_widget"):
self._loading_widget.set_progress(progress, 100)
self._loading_widget.set_status(message)
def on_complete(success: bool, error: str):
"""Handle save completion"""
# Hide loading widget
if hasattr(self, "_loading_widget"):
self._loading_widget.hide_loading()
if success:
self.project.file_path = file_path
self.project.mark_clean()
self.show_status(f"Project saved: {file_path}")
print(f"Successfully saved project to: {file_path}")
else:
error_msg = f"Failed to save project: {error}"
self.show_status(error_msg)
self.show_error("Save Failed", error_msg)
print(error_msg)
# Start async save
save_to_zip_async(
self.project,
file_path,
on_complete=on_complete,
on_progress=on_progress
)
# Show immediate feedback
self.show_status("Saving project in background...", 2000)
@ribbon_action(label="Heal Assets", tooltip="Reconnect missing image assets", tab="Home", group="File")
def heal_assets(self):

View File

@ -7,7 +7,8 @@ import json
import zipfile
import shutil
import tempfile
from typing import Optional, Tuple
import threading
from typing import Optional, Tuple, Callable
from pathlib import Path
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.version_manager import (
@ -171,6 +172,155 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
return False, error_msg
def save_to_zip_async(
project: Project,
zip_path: str,
on_complete: Optional[Callable[[bool, Optional[str]], None]] = None,
on_progress: Optional[Callable[[int, str], None]] = None,
) -> threading.Thread:
"""
Save a project to a ZIP file asynchronously in a background thread.
This provides instant UI responsiveness by:
1. Immediately serializing project.json to a temp folder (fast)
2. Creating the ZIP file in a background thread (slow)
3. Calling on_complete when done
Args:
project: The Project instance to save
zip_path: Path where the ZIP file should be created
on_complete: Optional callback(success: bool, error_msg: Optional[str])
called when save completes
on_progress: Optional callback(progress: int, message: str) where
progress is 0-100 and message describes current step
Returns:
The background thread (already started)
"""
def _background_save():
"""Background thread function to create the ZIP file."""
temp_dir = None
try:
# Report progress: Starting
if on_progress:
on_progress(0, "Preparing to save...")
# Ensure .ppz extension
final_zip_path = zip_path
if not final_zip_path.lower().endswith(".ppz"):
final_zip_path += ".ppz"
# Check for and import any external images before saving
if on_progress:
on_progress(5, "Checking for external images...")
_import_external_images(project)
# Serialize project to dictionary
if on_progress:
on_progress(10, "Serializing project data...")
project_data = project.serialize()
# Add version information
project_data["serialization_version"] = SERIALIZATION_VERSION
project_data["data_version"] = CURRENT_DATA_VERSION
# Create a temporary directory for staging
if on_progress:
on_progress(15, "Creating temporary staging area...")
temp_dir = tempfile.mkdtemp(prefix="pyPhotoAlbum_save_")
# Write project.json to temp directory
if on_progress:
on_progress(20, "Writing project metadata...")
temp_project_json = os.path.join(temp_dir, "project.json")
with open(temp_project_json, "w") as f:
json.dump(project_data, f, indent=2, sort_keys=True)
# Create temp ZIP file (not final location - for atomic write)
temp_zip_path = os.path.join(temp_dir, "project.ppz")
# Count assets for progress reporting
assets_folder = project.asset_manager.assets_folder
total_files = 1 # project.json
asset_files = []
if os.path.exists(assets_folder):
for root, dirs, files in os.walk(assets_folder):
for file in files:
asset_files.append((root, file))
total_files += 1
if on_progress:
on_progress(25, f"Creating ZIP archive ({total_files} files)...")
# Create ZIP file in temp location
with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
# Write project.json
zipf.write(temp_project_json, "project.json")
# Add all assets with progress reporting
if asset_files:
# Progress from 25% to 90% for assets
progress_range = 90 - 25
for idx, (root, file) in enumerate(asset_files):
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, project.folder_path)
zipf.write(file_path, arcname)
# Report progress every 10 files or at end
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)})"
)
# Atomic move: move temp ZIP to final location
if on_progress:
on_progress(95, "Finalizing save...")
# Ensure parent directory exists
os.makedirs(os.path.dirname(os.path.abspath(final_zip_path)), exist_ok=True)
# Remove old file if it exists
if os.path.exists(final_zip_path):
os.remove(final_zip_path)
# Move temp ZIP to final location (atomic on same filesystem)
shutil.move(temp_zip_path, final_zip_path)
if on_progress:
on_progress(100, "Save complete!")
print(f"Project saved to {final_zip_path}")
# Call completion callback with success
if on_complete:
on_complete(True, None)
except Exception as e:
error_msg = f"Error saving project: {str(e)}"
print(error_msg)
# Call completion callback with error
if on_complete:
on_complete(False, error_msg)
finally:
# Clean up temp directory
if temp_dir and os.path.exists(temp_dir):
try:
shutil.rmtree(temp_dir)
except Exception:
pass # Ignore cleanup errors
# Start background thread
save_thread = threading.Thread(target=_background_save, daemon=True)
save_thread.start()
return save_thread
def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
"""
Load a project from a ZIP file.

View File

@ -7,10 +7,10 @@ from typing import Optional, List, Tuple
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QFileDialog, QDockWidget
QLabel, QFileDialog, QDockWidget, QScrollBar
)
from PyQt6.QtCore import Qt, QSize, QMimeData, QUrl, QPoint
from PyQt6.QtGui import QDrag, QCursor
from PyQt6.QtGui import QDrag, QCursor, QPainter, QFont, QColor
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.gl_imports import *
@ -20,6 +20,15 @@ from pyPhotoAlbum.mixins.viewport import ViewportMixin
IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"]
class DateHeader:
"""Represents a date separator header in the thumbnail list."""
def __init__(self, date_text: str, y_position: float):
self.date_text = date_text
self.y = y_position
self.height = 30.0 # Height of the header bar
class ThumbnailItem:
"""Represents a thumbnail with position and path information."""
@ -61,6 +70,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
super().__init__()
self.thumbnails: List[ThumbnailItem] = []
self.date_headers: List[DateHeader] = []
self.current_folder: Optional[Path] = None
# Store reference to main window
@ -74,6 +84,14 @@ class ThumbnailGLWidget(QOpenGLWidget):
self.drag_start_pos = None
self.dragging_thumbnail = None
# Scrollbar (created but managed by parent)
self.scrollbar = None
self._updating_scrollbar = False # Flag to prevent circular updates
# Sort mode (set by parent dock)
self.sort_mode = "name"
self._get_image_date_func = None # Function to get date from parent
# Enable OpenGL
self.setMinimumSize(QSize(250, 300))
@ -105,6 +123,9 @@ class ThumbnailGLWidget(QOpenGLWidget):
# Rearrange thumbnails to fit new width
if hasattr(self, 'image_files') and self.image_files:
self._arrange_thumbnails()
else:
# Still update scrollbar even if no thumbnails
self._update_scrollbar_range()
def paintGL(self):
"""Render thumbnails."""
@ -118,10 +139,42 @@ class ThumbnailGLWidget(QOpenGLWidget):
glTranslatef(self.pan_offset[0], self.pan_offset[1], 0)
glScalef(self.zoom_level, self.zoom_level, 1.0)
# Render date headers first (so they appear behind thumbnails)
for header in self.date_headers:
self._render_date_header(header)
# Render each thumbnail (placeholders or textures)
for thumb in self.thumbnails:
self._render_thumbnail(thumb)
def paintEvent(self, event):
"""Override paintEvent to add text labels after OpenGL rendering."""
# Call the default OpenGL paint
super().paintEvent(event)
# Draw text labels for date headers using QPainter
if self.date_headers and self.sort_mode == "date":
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Set font for date labels
font = QFont("Arial", 11, QFont.Weight.Bold)
painter.setFont(font)
painter.setPen(QColor(255, 255, 255)) # White text
for header in self.date_headers:
# Transform header position to screen coordinates
screen_y = header.y * self.zoom_level + self.pan_offset[1]
screen_h = header.height * self.zoom_level
# Only draw if header is visible
if screen_y + screen_h >= 0 and screen_y <= self.height():
# Draw text centered vertically in the header bar
text_y = int(screen_y + screen_h / 2)
painter.drawText(10, text_y + 5, header.date_text)
painter.end()
def _render_thumbnail(self, thumb: ThumbnailItem):
"""Render a single thumbnail using placeholder pattern."""
x, y, w, h = thumb.get_bounds()
@ -195,6 +248,36 @@ class ThumbnailGLWidget(QOpenGLWidget):
glVertex2f(x, y + h)
glEnd()
def _render_date_header(self, header: DateHeader):
"""Render a date separator header."""
# Calculate full width bar
widget_width = self.width() / self.zoom_level
x = 0
y = header.y
w = widget_width
h = header.height
# Draw background bar (dark blue-gray)
glColor3f(0.3, 0.4, 0.5)
glBegin(GL_QUADS)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
# Draw bottom border
glColor3f(0.2, 0.3, 0.4)
glLineWidth(2.0)
glBegin(GL_LINES)
glVertex2f(x, y + h)
glVertex2f(x + w, y + h)
glEnd()
# Note: Text rendering would require QPainter overlay
# For now, the colored bar serves as a visual separator
# Text will be added using QPainter in a future enhancement
def _create_texture_for_thumbnail(self, thumb: ThumbnailItem):
"""Create OpenGL texture from pending PIL image."""
if not thumb._pending_pil_image:
@ -282,33 +365,140 @@ class ThumbnailGLWidget(QOpenGLWidget):
# Build a map of existing thumbnails by path to reuse them
existing_thumbs = {thumb.image_path: thumb for thumb in self.thumbnails}
# Clear list but reuse thumbnail objects
# Clear lists but reuse thumbnail objects
self.thumbnails.clear()
self.date_headers.clear()
# For date mode: track current date and positioning
current_date_str = None
section_start_y = spacing
row_in_section = 0
col = 0
for idx, image_file in enumerate(self.image_files):
row = idx // columns
col = idx % columns
image_path = str(image_file)
# 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"
if date_str != current_date_str:
# Starting a new date section
if current_date_str is not None:
# Not the first section - calculate where this section starts
# It should start after the last thumbnail of the previous section
if self.thumbnails:
last_thumb = self.thumbnails[-1]
# Start after the last row of previous section
last_row_y = last_thumb.y + last_thumb.thumbnail_size
section_start_y = last_row_y + spacing * 2 # Extra spacing between sections
# Add header at section start
header = DateHeader(date_str, section_start_y)
self.date_headers.append(header)
# Update section_start_y to after the header
section_start_y += header.height + spacing
current_date_str = date_str
row_in_section = 0
col = 0
# Calculate position
if self.sort_mode == "date":
# In date mode: position relative to section start
row = row_in_section
thumb_y = section_start_y + row * (100.0 + spacing)
else:
# In other modes: simple grid based on overall index
row = idx // columns
thumb_y = row * (100.0 + spacing) + spacing
# Calculate X position (always centered)
thumb_x = h_offset + col * (100.0 + spacing) + spacing
# Reuse existing thumbnail if available, otherwise create new
if image_path in existing_thumbs:
thumb = existing_thumbs[image_path]
# Update grid position
thumb.grid_row = row
thumb.grid_col = col
# Recalculate position with horizontal centering
thumb.x = h_offset + col * (thumb.thumbnail_size + spacing) + spacing
thumb.y = row * (thumb.thumbnail_size + spacing) + spacing
thumb.x = thumb_x
thumb.y = thumb_y
else:
# Create new placeholder thumbnail with horizontal centering
# Create new placeholder thumbnail
thumb = ThumbnailItem(image_path, (row, col))
thumb.x = h_offset + col * (thumb.thumbnail_size + spacing) + spacing
thumb.y = row * (thumb.thumbnail_size + spacing) + spacing
# Request async load (will be skipped if already loading/loaded)
thumb.x = thumb_x
thumb.y = thumb_y
# Request async load
self._request_thumbnail_load(thumb)
self.thumbnails.append(thumb)
# Update column and row counters
col += 1
if col >= columns:
col = 0
row_in_section += 1
# Update scrollbar range after arranging
self._update_scrollbar_range()
def _update_scrollbar_range(self):
"""Update scrollbar range based on content height."""
if not self.scrollbar or self._updating_scrollbar:
return
if not self.thumbnails:
self.scrollbar.setRange(0, 0)
self.scrollbar.setPageStep(self.height())
return
# Calculate total content height
if self.thumbnails:
# Find the maximum Y position
max_y = max(thumb.y + thumb.thumbnail_size for thumb in self.thumbnails)
content_height = max_y * self.zoom_level
else:
content_height = 0
# Visible height
visible_height = self.height()
# Scrollable range
scroll_range = max(0, int(content_height - visible_height))
self._updating_scrollbar = True
self.scrollbar.setRange(0, scroll_range)
self.scrollbar.setPageStep(visible_height)
self.scrollbar.setSingleStep(int(visible_height / 10)) # 10% of visible height per step
# Update scrollbar position based on current pan
scroll_pos = int(-self.pan_offset[1])
self.scrollbar.setValue(scroll_pos)
self._updating_scrollbar = False
def _on_scrollbar_changed(self, value):
"""Handle scrollbar value change."""
if self._updating_scrollbar:
return
# Update pan offset based on scrollbar value
self.pan_offset = (0, -value)
self.update()
def _update_scrollbar_position(self):
"""Update scrollbar position based on current pan offset."""
if not self.scrollbar or self._updating_scrollbar:
return
self._updating_scrollbar = True
scroll_pos = int(-self.pan_offset[1])
self.scrollbar.setValue(scroll_pos)
self._updating_scrollbar = False
def update_used_images(self):
"""Update which thumbnails are already used in the project."""
# Get reference to main window's project
@ -438,6 +628,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
self.pan_offset[1] + delta.y()
)
self.drag_start_pos = event.pos()
self._update_scrollbar_position()
self.update()
def mouseReleaseEvent(self, event):
@ -483,6 +674,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
self.pan_offset[1] + scroll_amount
)
self._update_scrollbar_position()
self.update()
@ -513,9 +705,54 @@ class ThumbnailBrowserDock(QDockWidget):
layout.addLayout(header_layout)
# Sort toolbar
sort_layout = QHBoxLayout()
sort_layout.setContentsMargins(5, 0, 5, 5)
sort_label = QLabel("Sort by:")
sort_layout.addWidget(sort_label)
self.sort_name_btn = QPushButton("Name")
self.sort_name_btn.setCheckable(True)
self.sort_name_btn.setChecked(True) # Default sort
self.sort_name_btn.clicked.connect(lambda: self._sort_by("name"))
sort_layout.addWidget(self.sort_name_btn)
self.sort_date_btn = QPushButton("Date")
self.sort_date_btn.setCheckable(True)
self.sort_date_btn.clicked.connect(lambda: self._sort_by("date"))
sort_layout.addWidget(self.sort_date_btn)
self.sort_camera_btn = QPushButton("Camera")
self.sort_camera_btn.setCheckable(True)
self.sort_camera_btn.clicked.connect(lambda: self._sort_by("camera"))
sort_layout.addWidget(self.sort_camera_btn)
sort_layout.addStretch()
layout.addLayout(sort_layout)
# Track current sort mode
self.current_sort = "name"
# Create horizontal layout for GL widget and scrollbar
browser_layout = QHBoxLayout()
browser_layout.setContentsMargins(0, 0, 0, 0)
browser_layout.setSpacing(0)
# GL Widget for thumbnails
self.gl_widget = ThumbnailGLWidget(main_window=parent)
layout.addWidget(self.gl_widget)
browser_layout.addWidget(self.gl_widget)
# Vertical scrollbar
self.scrollbar = QScrollBar(Qt.Orientation.Vertical)
self.scrollbar.valueChanged.connect(self.gl_widget._on_scrollbar_changed)
browser_layout.addWidget(self.scrollbar)
# Connect scrollbar to GL widget
self.gl_widget.scrollbar = self.scrollbar
layout.addLayout(browser_layout)
self.setWidget(main_widget)
@ -573,3 +810,96 @@ class ThumbnailBrowserDock(QDockWidget):
"""Load thumbnails from folder."""
self.folder_label.setText(f"Folder: {folder_path.name}")
self.gl_widget.load_folder(folder_path)
# Apply current sort after loading
self._apply_sort()
self.gl_widget._arrange_thumbnails()
self.gl_widget.update_used_images()
self.gl_widget.update()
def _sort_by(self, sort_mode: str):
"""Sort thumbnails by the specified mode."""
# Update button states (only one can be checked)
self.sort_name_btn.setChecked(sort_mode == "name")
self.sort_date_btn.setChecked(sort_mode == "date")
self.sort_camera_btn.setChecked(sort_mode == "camera")
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:
self._apply_sort()
# Re-arrange thumbnails with new order
self.gl_widget._arrange_thumbnails()
self.gl_widget.update_used_images()
self.gl_widget.update()
def _apply_sort(self):
"""Apply current sort mode to image files."""
if self.current_sort == "name":
# Sort by filename only (not full path)
self.gl_widget.image_files.sort(key=lambda p: p.name.lower())
# Clear date headers for non-date sorts
self.gl_widget.date_headers.clear()
# Reset sort mode in GL widget
self.gl_widget.sort_mode = "name"
self.gl_widget._get_image_date_func = None
elif self.current_sort == "date":
# Sort by file modification time (or EXIF date if available)
self.gl_widget.image_files.sort(key=self._get_image_date)
# Date headers will be created during _arrange_thumbnails
self.gl_widget.sort_mode = "date"
self.gl_widget._get_image_date_func = self._get_image_date
elif self.current_sort == "camera":
# Sort by camera model from EXIF
self.gl_widget.image_files.sort(key=self._get_camera_model)
# Clear date headers for non-date sorts
self.gl_widget.date_headers.clear()
# Reset sort mode in GL widget
self.gl_widget.sort_mode = "camera"
self.gl_widget._get_image_date_func = None
def _get_image_date(self, image_path: Path) -> float:
"""Get image date from EXIF or file modification time."""
try:
from PIL import Image
from PIL.ExifTags import TAGS
with Image.open(image_path) as img:
exif = img.getexif()
if exif:
# Look for DateTimeOriginal (when photo was taken)
for tag_id, value in exif.items():
tag = TAGS.get(tag_id, tag_id)
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()
except:
pass
except Exception:
pass
# Fallback to file modification time
return image_path.stat().st_mtime
def _get_camera_model(self, image_path: Path) -> str:
"""Get camera model from EXIF metadata."""
try:
from PIL import Image
from PIL.ExifTags import TAGS
with Image.open(image_path) as img:
exif = img.getexif()
if exif:
# Look for camera model
for tag_id, value in exif.items():
tag = TAGS.get(tag_id, tag_id)
if tag == "Model":
return str(value).strip()
except Exception:
pass
# Fallback to filename if no EXIF data
return image_path.name.lower()