diff --git a/pyPhotoAlbum/async_project_loader.py b/pyPhotoAlbum/async_project_loader.py new file mode 100644 index 0000000..4b171fd --- /dev/null +++ b/pyPhotoAlbum/async_project_loader.py @@ -0,0 +1,251 @@ +""" +Async project loader for pyPhotoAlbum + +Loads projects asynchronously with progress updates to prevent UI freezing. +""" + +import os +import json +import zipfile +import tempfile +from typing import Optional, Tuple +from pathlib import Path +from PyQt6.QtCore import QThread, pyqtSignal + +from pyPhotoAlbum.project import Project +from pyPhotoAlbum.models import ImageData, set_asset_resolution_context +from pyPhotoAlbum.version_manager import ( + CURRENT_DATA_VERSION, + check_version_compatibility, + VersionCompatibility, + DataMigration +) + + +class AsyncProjectLoader(QThread): + """ + Async worker thread for loading projects from ZIP files. + + Signals: + progress_updated(int, int, str): Emitted with (current, total, message) + load_complete(Project): Emitted when loading succeeds + load_failed(str): Emitted when loading fails with error message + """ + + progress_updated = pyqtSignal(int, int, str) # current, total, message + load_complete = pyqtSignal(object) # Project instance + load_failed = pyqtSignal(str) # error message + + def __init__(self, zip_path: str, extract_to: Optional[str] = None): + super().__init__() + self.zip_path = zip_path + self.extract_to = extract_to + self._cancelled = False + + def cancel(self): + """Cancel the loading operation""" + self._cancelled = True + + def run(self): + """Run the async loading operation""" + try: + if not os.path.exists(self.zip_path): + self.load_failed.emit(f"ZIP file not found: {self.zip_path}") + return + + if self._cancelled: + return + + # Progress: Starting + self.progress_updated.emit(0, 100, "Preparing to load...") + + # Track if we created a temp directory + temp_dir_obj = None + + # Determine extraction directory + if self.extract_to is None: + zip_basename = os.path.splitext(os.path.basename(self.zip_path))[0] + temp_dir_obj = tempfile.TemporaryDirectory(prefix=f"pyPhotoAlbum_{zip_basename}_") + extract_to = temp_dir_obj.name + else: + os.makedirs(self.extract_to, exist_ok=True) + extract_to = self.extract_to + + if self._cancelled: + return + + # Progress: Extracting ZIP + self.progress_updated.emit(10, 100, "Extracting project files...") + + # Extract ZIP contents with progress + with zipfile.ZipFile(self.zip_path, 'r') as zipf: + file_list = zipf.namelist() + total_files = len(file_list) + + for i, filename in enumerate(file_list): + if self._cancelled: + return + + zipf.extract(filename, extract_to) + + # Update progress every 10 files or on last file + if i % 10 == 0 or i == total_files - 1: + progress = 10 + int((i / total_files) * 30) # 10-40% + self.progress_updated.emit( + progress, 100, + f"Extracting files... ({i + 1}/{total_files})" + ) + + if self._cancelled: + return + + # Progress: Loading project data + self.progress_updated.emit(45, 100, "Loading project data...") + + # Load project.json + project_json_path = os.path.join(extract_to, 'project.json') + if not os.path.exists(project_json_path): + self.load_failed.emit("Invalid project file: project.json not found") + return + + with open(project_json_path, 'r') as f: + project_data = json.load(f) + + if self._cancelled: + return + + # Progress: Checking version + self.progress_updated.emit(55, 100, "Checking version compatibility...") + + # Check version compatibility + file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0')) + + is_compatible, error_msg = check_version_compatibility(file_version, self.zip_path) + if not is_compatible: + self.load_failed.emit(error_msg) + return + + # Apply migrations if needed + if VersionCompatibility.needs_migration(file_version): + self.progress_updated.emit(60, 100, f"Migrating from version {file_version}...") + try: + project_data = DataMigration.migrate(project_data, file_version, CURRENT_DATA_VERSION) + except Exception as e: + self.load_failed.emit(f"Migration failed: {str(e)}") + return + + if self._cancelled: + return + + # Progress: Creating project + self.progress_updated.emit(70, 100, "Creating project...") + + # Create new project + project_name = project_data.get('name', 'Untitled Project') + project = Project(name=project_name, folder_path=extract_to) + + # Deserialize project data + project.deserialize(project_data) + + # Update folder path to extraction location + project.folder_path = extract_to + project.asset_manager.project_folder = extract_to + project.asset_manager.assets_folder = os.path.join(extract_to, "assets") + + # Attach temporary directory to project (if we created one) + if temp_dir_obj is not None: + project._temp_dir = temp_dir_obj + + if self._cancelled: + return + + # Progress: Normalizing paths + self.progress_updated.emit(85, 100, "Normalizing asset paths...") + + # Normalize asset paths + self._normalize_asset_paths(project, extract_to) + + # Progress: Setting up asset resolution + self.progress_updated.emit(95, 100, "Setting up asset resolution...") + + # Set asset resolution context + zip_directory = os.path.dirname(os.path.abspath(self.zip_path)) + set_asset_resolution_context(extract_to, additional_search_paths=[zip_directory]) + + if self._cancelled: + return + + # Progress: Complete + self.progress_updated.emit(100, 100, "Loading complete!") + + # Emit success + self.load_complete.emit(project) + + except Exception as e: + error_msg = f"Error loading project: {str(e)}" + self.load_failed.emit(error_msg) + + def _normalize_asset_paths(self, project: Project, project_folder: str): + """ + Normalize asset paths in a loaded project to be relative to the project folder. + """ + normalized_count = 0 + + for page in project.pages: + for element in page.layout.elements: + if isinstance(element, ImageData) and element.image_path: + original_path = element.image_path + + # Skip if already a simple relative path + if not os.path.isabs(original_path) and not original_path.startswith('./projects/'): + continue + + # Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg" + if '/assets/' in original_path: + parts = original_path.split('/assets/') + if len(parts) == 2: + new_path = os.path.join('assets', parts[1]) + element.image_path = new_path + normalized_count += 1 + continue + + # Pattern 2: Absolute path - try to make it relative + if os.path.isabs(original_path): + try: + new_path = os.path.relpath(original_path, project_folder) + element.image_path = new_path + normalized_count += 1 + except ValueError: + pass + + if normalized_count > 0: + print(f"Normalized {normalized_count} asset paths") + + +def load_from_zip_async(zip_path: str, extract_to: Optional[str] = None, + progress_callback=None, complete_callback=None, + error_callback=None) -> AsyncProjectLoader: + """ + Load a project from a ZIP file asynchronously. + + Args: + zip_path: Path to the ZIP file to load + extract_to: Optional directory to extract to. If None, uses a temporary directory. + progress_callback: Optional callback(current, total, message) for progress updates + complete_callback: Optional callback(project) when loading completes + error_callback: Optional callback(error_msg) when loading fails + + Returns: + AsyncProjectLoader instance (already started) + """ + loader = AsyncProjectLoader(zip_path, extract_to) + + if progress_callback: + loader.progress_updated.connect(progress_callback) + if complete_callback: + loader.load_complete.connect(complete_callback) + if error_callback: + loader.load_failed.connect(error_callback) + + loader.start() + return loader diff --git a/pyPhotoAlbum/loading_widget.py b/pyPhotoAlbum/loading_widget.py new file mode 100644 index 0000000..a751213 --- /dev/null +++ b/pyPhotoAlbum/loading_widget.py @@ -0,0 +1,184 @@ +""" +Loading progress widget for pyPhotoAlbum + +Displays loading progress in the lower-right corner of the window. +""" + +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar +from PyQt6.QtCore import Qt, QPropertyAnimation, QEasingCurve, pyqtProperty +from PyQt6.QtGui import QPalette, QColor + + +class LoadingWidget(QWidget): + """ + A widget that displays loading progress in the lower-right corner. + + Features: + - Fade in/out animations + - Progress bar with percentage + - Status message display + - Compact, non-intrusive design + """ + + def __init__(self, parent=None): + super().__init__(parent) + + # Widget configuration + self.setWindowFlags(Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, False) + self.setFixedSize(280, 80) + + # Styling + self.setStyleSheet(""" + QWidget { + background-color: rgba(50, 50, 50, 230); + border-radius: 8px; + border: 1px solid rgba(100, 100, 100, 180); + } + QLabel { + color: white; + background-color: transparent; + font-size: 11pt; + } + QProgressBar { + border: 1px solid rgba(80, 80, 80, 180); + border-radius: 4px; + background-color: rgba(30, 30, 30, 200); + text-align: center; + color: white; + font-size: 10pt; + } + QProgressBar::chunk { + background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 rgba(70, 130, 180, 220), + stop:1 rgba(100, 160, 210, 220)); + border-radius: 3px; + } + """) + + # Layout + layout = QVBoxLayout() + layout.setContentsMargins(12, 10, 12, 10) + layout.setSpacing(8) + + # Status label + self._status_label = QLabel("Loading...") + self._status_label.setAlignment(Qt.AlignmentFlag.AlignLeft) + layout.addWidget(self._status_label) + + # Progress bar with percentage label + progress_layout = QHBoxLayout() + progress_layout.setSpacing(8) + + self._progress_bar = QProgressBar() + self._progress_bar.setMinimum(0) + self._progress_bar.setMaximum(100) + self._progress_bar.setValue(0) + self._progress_bar.setTextVisible(True) + self._progress_bar.setFormat("%p%") + progress_layout.addWidget(self._progress_bar, 1) + + layout.addLayout(progress_layout) + + self.setLayout(layout) + + # Animation for fade in/out + self._opacity = 1.0 + self._fade_animation = QPropertyAnimation(self, b"opacity") + self._fade_animation.setDuration(300) + self._fade_animation.setEasingCurve(QEasingCurve.Type.InOutQuad) + + # Initially hidden + self.hide() + + @pyqtProperty(float) + def opacity(self): + """Get opacity for animation""" + return self._opacity + + @opacity.setter + def opacity(self, value): + """Set opacity for animation""" + self._opacity = value + self.setWindowOpacity(value) + + def show_loading(self, message: str = "Loading..."): + """ + Show the loading widget with a fade-in animation. + + Args: + message: Initial status message + """ + self.set_status(message) + self.set_progress(0) + + # Position in lower-right corner of parent + self._reposition() + + # Fade in + self.show() + self._fade_animation.stop() + self._fade_animation.setStartValue(0.0) + self._fade_animation.setEndValue(1.0) + self._fade_animation.start() + + def hide_loading(self): + """Hide the loading widget with a fade-out animation.""" + self._fade_animation.stop() + self._fade_animation.setStartValue(1.0) + self._fade_animation.setEndValue(0.0) + self._fade_animation.finished.connect(self.hide) + self._fade_animation.start() + + def set_status(self, message: str): + """ + Update the status message. + + Args: + message: Status message to display + """ + self._status_label.setText(message) + + def set_progress(self, value: int, maximum: int = 100): + """ + Update the progress bar. + + Args: + value: Current progress value + maximum: Maximum progress value (default: 100) + """ + self._progress_bar.setMaximum(maximum) + self._progress_bar.setValue(value) + + def set_indeterminate(self, indeterminate: bool = True): + """ + Set the progress bar to indeterminate mode (busy indicator). + + Args: + indeterminate: True for indeterminate, False for normal progress + """ + if indeterminate: + self._progress_bar.setMinimum(0) + self._progress_bar.setMaximum(0) + else: + self._progress_bar.setMinimum(0) + self._progress_bar.setMaximum(100) + + def _reposition(self): + """Position the widget in the lower-right corner of the parent.""" + if self.parent(): + parent_rect = self.parent().rect() + margin = 20 + x = parent_rect.width() - self.width() - margin + y = parent_rect.height() - self.height() - margin + self.move(x, y) + + def showEvent(self, event): + """Handle show event to reposition.""" + super().showEvent(event) + self._reposition() + + def resizeParent(self): + """Call this when parent is resized to reposition the widget.""" + if self.isVisible(): + self._reposition() diff --git a/pyPhotoAlbum/main.py b/pyPhotoAlbum/main.py index 5a0bdfd..ce43681 100644 --- a/pyPhotoAlbum/main.py +++ b/pyPhotoAlbum/main.py @@ -131,21 +131,27 @@ class MainWindow( """Register keyboard shortcuts from decorated methods""" from PyQt6.QtGui import QShortcut, QKeySequence from pyPhotoAlbum.ribbon_builder import get_keyboard_shortcuts - + shortcuts = get_keyboard_shortcuts(self.__class__) - + for shortcut_str, method_name in shortcuts.items(): if hasattr(self, method_name): shortcut = QShortcut(QKeySequence(shortcut_str), self) method = getattr(self, method_name) shortcut.activated.connect(method) print(f"Registered shortcut: {shortcut_str} -> {method_name}") - + # Register additional Ctrl+Shift+Z shortcut for redo if hasattr(self, 'redo'): redo_shortcut = QShortcut(QKeySequence("Ctrl+Shift+Z"), self) redo_shortcut.activated.connect(self.redo) print("Registered shortcut: Ctrl+Shift+Z -> redo") + + def resizeEvent(self, event): + """Handle window resize to reposition loading widget""" + super().resizeEvent(event) + if hasattr(self, '_loading_widget'): + self._loading_widget.resizeParent() def _add_sample_page(self): """Add a sample page with some elements for demonstration""" diff --git a/pyPhotoAlbum/mixins/operations/file_ops.py b/pyPhotoAlbum/mixins/operations/file_ops.py index 23e22fe..161e37e 100644 --- a/pyPhotoAlbum/mixins/operations/file_ops.py +++ b/pyPhotoAlbum/mixins/operations/file_ops.py @@ -2,9 +2,19 @@ File operations mixin for pyPhotoAlbum """ -from PyQt6.QtWidgets import QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton, QButtonGroup +from PyQt6.QtWidgets import ( + QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton, + QButtonGroup, QLineEdit, QTextEdit +) from pyPhotoAlbum.decorators import ribbon_action, numerical_input -from pyPhotoAlbum.project import Project +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.models import set_asset_resolution_context +from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION +from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog class FileOperationsMixin: @@ -19,8 +29,6 @@ class FileOperationsMixin: ) def new_project(self): """Create a new project with initial setup dialog""" - from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QLineEdit - # Create new project setup dialog dialog = QDialog(self) dialog.setWindowTitle("New Project Setup") @@ -153,7 +161,6 @@ class FileOperationsMixin: self.project.export_dpi = export_dpi # Set asset resolution context - from pyPhotoAlbum.models import set_asset_resolution_context set_asset_resolution_context(self.project.folder_path) # Update view @@ -173,36 +180,71 @@ class FileOperationsMixin: shortcut="Ctrl+O" ) def open_project(self): - """Open an existing project""" - from pyPhotoAlbum.project_serializer import load_from_zip - + """Open an existing project with async loading and progress bar""" file_path, _ = QFileDialog.getOpenFileName( self, "Open Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)" ) - + if file_path: print(f"Opening project: {file_path}") - - # Cleanup old project if it exists - if hasattr(self, 'project') and self.project: - self.project.cleanup() - # Load project from ZIP - project, error = load_from_zip(file_path) + # Create loading widget if not exists + if not hasattr(self, '_loading_widget'): + self._loading_widget = LoadingWidget(self) - if project: - self.project = project - self.current_page_index = 0 # Reset to first page - self.update_view() - self.show_status(f"Project opened: {file_path}") - print(f"Successfully loaded project: {project.name}") - else: - error_msg = f"Failed to open project: {error}" - self.show_status(error_msg) - print(error_msg) + # Show loading widget + self._loading_widget.show_loading("Opening project...") + + # Create and configure async loader + self._project_loader = AsyncProjectLoader(file_path) + + # Connect signals + self._project_loader.progress_updated.connect(self._on_load_progress) + self._project_loader.load_complete.connect(self._on_load_complete) + self._project_loader.load_failed.connect(self._on_load_failed) + + # Start async loading + self._project_loader.start() + + def _on_load_progress(self, current: int, total: int, message: str): + """Handle loading progress updates""" + if hasattr(self, '_loading_widget'): + self._loading_widget.set_progress(current, total) + self._loading_widget.set_status(message) + + def _on_load_complete(self, project): + """Handle successful project load""" + # Cleanup old project if it exists + if hasattr(self, 'project') and self.project: + self.project.cleanup() + + # Set new project + self.project = project + self.gl_widget.current_page_index = 0 # Reset to first page + + # Hide loading widget + if hasattr(self, '_loading_widget'): + self._loading_widget.hide_loading() + + # Update view (this will trigger progressive image loading) + self.update_view() + + self.show_status(f"Project opened: {project.name}") + print(f"Successfully loaded project: {project.name}") + + def _on_load_failed(self, error_msg: str): + """Handle project load failure""" + # Hide loading widget + if hasattr(self, '_loading_widget'): + self._loading_widget.hide_loading() + + error_msg = f"Failed to open project: {error_msg}" + self.show_status(error_msg) + self.show_error("Load Failed", error_msg) + print(error_msg) @ribbon_action( label="Save", @@ -213,8 +255,6 @@ class FileOperationsMixin: ) def save_project(self): """Save the current project""" - from pyPhotoAlbum.project_serializer import save_to_zip - file_path, _ = QFileDialog.getSaveFileName( self, "Save Project", @@ -244,8 +284,6 @@ class FileOperationsMixin: ) def heal_assets(self): """Open the asset healing dialog to reconnect missing images""" - from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog - dialog = AssetHealDialog(self.project, self) dialog.exec() @@ -529,9 +567,6 @@ class FileOperationsMixin: ) def show_about(self): """Show about dialog with version information""" - from PyQt6.QtWidgets import QTextEdit - from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION - dialog = QDialog(self) dialog.setWindowTitle("About pyPhotoAlbum") dialog.setMinimumWidth(600) diff --git a/pyPhotoAlbum/pdf_exporter.py b/pyPhotoAlbum/pdf_exporter.py index f03753a..9844c0d 100644 --- a/pyPhotoAlbum/pdf_exporter.py +++ b/pyPhotoAlbum/pdf_exporter.py @@ -491,14 +491,11 @@ class PDFExporter: current_width, current_height = cropped_img.size if current_width > target_width_px or current_height > target_height_px: # Use LANCZOS resampling for high quality downsampling - cropped_img = cropped_img.resize((target_width_px, target_height_px), + cropped_img = cropped_img.resize((target_width_px, target_height_px), Image.Resampling.LANCZOS) - - # Apply rotation if needed - if image_element.rotation != 0: - # Rotate around center - cropped_img = cropped_img.rotate(-image_element.rotation, expand=True, - fillcolor=(255, 255, 255, 0)) + + # Note: Rotation is applied at the canvas level (below), not here + # to avoid double-rotation issues # Save state for transformations c.saveState() @@ -509,7 +506,7 @@ class PDFExporter: center_x = x_pt + width_pt / 2 center_y = y_pt + height_pt / 2 c.translate(center_x, center_y) - c.rotate(image_element.rotation) + c.rotate(-image_element.rotation) c.translate(-width_pt / 2, -height_pt / 2) # Draw at origin after transformation c.drawImage(ImageReader(cropped_img), 0, 0, width_pt, height_pt, diff --git a/test_loading_widget.py b/test_loading_widget.py new file mode 100644 index 0000000..4565b19 --- /dev/null +++ b/test_loading_widget.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +""" +Test script for the loading widget functionality +""" + +import sys +import time +from pathlib import Path +from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton, QVBoxLayout, QWidget +from PyQt6.QtCore import QTimer + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent)) + +from pyPhotoAlbum.loading_widget import LoadingWidget + + +class TestWindow(QMainWindow): + """Test window for loading widget""" + + def __init__(self): + super().__init__() + self.setWindowTitle("Loading Widget Test") + self.resize(800, 600) + + # Central widget + central = QWidget() + layout = QVBoxLayout() + + # Test buttons + btn1 = QPushButton("Show Loading (Determinate)") + btn1.clicked.connect(self.test_determinate) + layout.addWidget(btn1) + + btn2 = QPushButton("Show Loading (Indeterminate)") + btn2.clicked.connect(self.test_indeterminate) + layout.addWidget(btn2) + + btn3 = QPushButton("Simulate File Loading") + btn3.clicked.connect(self.simulate_file_loading) + layout.addWidget(btn3) + + central.setLayout(layout) + self.setCentralWidget(central) + + # Create loading widget + self.loading_widget = LoadingWidget(self) + + # Timer for progress simulation + self.timer = QTimer() + self.timer.timeout.connect(self.update_progress) + self.progress = 0 + + def test_determinate(self): + """Test determinate progress""" + self.loading_widget.show_loading("Loading...") + self.loading_widget.set_indeterminate(False) + self.loading_widget.set_progress(50, 100) + + # Auto hide after 3 seconds + QTimer.singleShot(3000, self.loading_widget.hide_loading) + + def test_indeterminate(self): + """Test indeterminate progress""" + self.loading_widget.show_loading("Processing...") + self.loading_widget.set_indeterminate(True) + + # Auto hide after 3 seconds + QTimer.singleShot(3000, self.loading_widget.hide_loading) + + def simulate_file_loading(self): + """Simulate file loading with progress""" + self.progress = 0 + self.loading_widget.show_loading("Extracting files...") + self.loading_widget.set_indeterminate(False) + self.timer.start(100) # Update every 100ms + + def update_progress(self): + """Update progress during simulation""" + self.progress += 5 + + if self.progress <= 40: + self.loading_widget.set_status(f"Extracting files... ({self.progress}%)") + elif self.progress <= 70: + self.loading_widget.set_status("Loading project data...") + elif self.progress <= 95: + self.loading_widget.set_status("Normalizing asset paths...") + else: + self.loading_widget.set_status("Loading complete!") + + self.loading_widget.set_progress(self.progress, 100) + + if self.progress >= 100: + self.timer.stop() + QTimer.singleShot(500, self.loading_widget.hide_loading) + + def resizeEvent(self, event): + """Handle resize""" + super().resizeEvent(event) + self.loading_widget.resizeParent() + + +def main(): + """Run test""" + app = QApplication(sys.argv) + window = TestWindow() + window.show() + + print("Loading widget test window opened") + print("Click buttons to test different loading states") + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main()