From c1ee894e7b28cc5c1fcb3c2a34881de698adb03f Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 13 Dec 2025 16:53:43 +0100 Subject: [PATCH] Sort library tab, background saving --- pyPhotoAlbum/mixins/operations/file_ops.py | 55 +++- pyPhotoAlbum/project_serializer.py | 152 ++++++++- pyPhotoAlbum/thumbnail_browser.py | 358 ++++++++++++++++++++- 3 files changed, 537 insertions(+), 28 deletions(-) diff --git a/pyPhotoAlbum/mixins/operations/file_ops.py b/pyPhotoAlbum/mixins/operations/file_ops.py index 5c3ec15..02fafaf 100644 --- a/pyPhotoAlbum/mixins/operations/file_ops.py +++ b/pyPhotoAlbum/mixins/operations/file_ops.py @@ -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): diff --git a/pyPhotoAlbum/project_serializer.py b/pyPhotoAlbum/project_serializer.py index d4066a1..1efcba5 100644 --- a/pyPhotoAlbum/project_serializer.py +++ b/pyPhotoAlbum/project_serializer.py @@ -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. diff --git a/pyPhotoAlbum/thumbnail_browser.py b/pyPhotoAlbum/thumbnail_browser.py index ae70024..ede5993 100644 --- a/pyPhotoAlbum/thumbnail_browser.py +++ b/pyPhotoAlbum/thumbnail_browser.py @@ -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()