Async loading and use of temp file
This commit is contained in:
parent
1b0e6f9ed1
commit
950aa6bee9
251
pyPhotoAlbum/async_project_loader.py
Normal file
251
pyPhotoAlbum/async_project_loader.py
Normal file
@ -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
|
||||||
184
pyPhotoAlbum/loading_widget.py
Normal file
184
pyPhotoAlbum/loading_widget.py
Normal file
@ -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()
|
||||||
@ -147,6 +147,12 @@ class MainWindow(
|
|||||||
redo_shortcut.activated.connect(self.redo)
|
redo_shortcut.activated.connect(self.redo)
|
||||||
print("Registered shortcut: Ctrl+Shift+Z -> 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):
|
def _add_sample_page(self):
|
||||||
"""Add a sample page with some elements for demonstration"""
|
"""Add a sample page with some elements for demonstration"""
|
||||||
from pyPhotoAlbum.project import Page
|
from pyPhotoAlbum.project import Page
|
||||||
|
|||||||
@ -2,9 +2,19 @@
|
|||||||
File operations mixin for pyPhotoAlbum
|
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.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:
|
class FileOperationsMixin:
|
||||||
@ -19,8 +29,6 @@ class FileOperationsMixin:
|
|||||||
)
|
)
|
||||||
def new_project(self):
|
def new_project(self):
|
||||||
"""Create a new project with initial setup dialog"""
|
"""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
|
# Create new project setup dialog
|
||||||
dialog = QDialog(self)
|
dialog = QDialog(self)
|
||||||
dialog.setWindowTitle("New Project Setup")
|
dialog.setWindowTitle("New Project Setup")
|
||||||
@ -153,7 +161,6 @@ class FileOperationsMixin:
|
|||||||
self.project.export_dpi = export_dpi
|
self.project.export_dpi = export_dpi
|
||||||
|
|
||||||
# Set asset resolution context
|
# Set asset resolution context
|
||||||
from pyPhotoAlbum.models import set_asset_resolution_context
|
|
||||||
set_asset_resolution_context(self.project.folder_path)
|
set_asset_resolution_context(self.project.folder_path)
|
||||||
|
|
||||||
# Update view
|
# Update view
|
||||||
@ -173,9 +180,7 @@ class FileOperationsMixin:
|
|||||||
shortcut="Ctrl+O"
|
shortcut="Ctrl+O"
|
||||||
)
|
)
|
||||||
def open_project(self):
|
def open_project(self):
|
||||||
"""Open an existing project"""
|
"""Open an existing project with async loading and progress bar"""
|
||||||
from pyPhotoAlbum.project_serializer import load_from_zip
|
|
||||||
|
|
||||||
file_path, _ = QFileDialog.getOpenFileName(
|
file_path, _ = QFileDialog.getOpenFileName(
|
||||||
self,
|
self,
|
||||||
"Open Project",
|
"Open Project",
|
||||||
@ -186,22 +191,59 @@ class FileOperationsMixin:
|
|||||||
if file_path:
|
if file_path:
|
||||||
print(f"Opening project: {file_path}")
|
print(f"Opening project: {file_path}")
|
||||||
|
|
||||||
|
# Create loading widget if not exists
|
||||||
|
if not hasattr(self, '_loading_widget'):
|
||||||
|
self._loading_widget = LoadingWidget(self)
|
||||||
|
|
||||||
|
# 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
|
# Cleanup old project if it exists
|
||||||
if hasattr(self, 'project') and self.project:
|
if hasattr(self, 'project') and self.project:
|
||||||
self.project.cleanup()
|
self.project.cleanup()
|
||||||
|
|
||||||
# Load project from ZIP
|
# Set new project
|
||||||
project, error = load_from_zip(file_path)
|
|
||||||
|
|
||||||
if project:
|
|
||||||
self.project = project
|
self.project = project
|
||||||
self.current_page_index = 0 # Reset to first page
|
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.update_view()
|
||||||
self.show_status(f"Project opened: {file_path}")
|
|
||||||
|
self.show_status(f"Project opened: {project.name}")
|
||||||
print(f"Successfully loaded project: {project.name}")
|
print(f"Successfully loaded project: {project.name}")
|
||||||
else:
|
|
||||||
error_msg = f"Failed to open project: {error}"
|
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_status(error_msg)
|
||||||
|
self.show_error("Load Failed", error_msg)
|
||||||
print(error_msg)
|
print(error_msg)
|
||||||
|
|
||||||
@ribbon_action(
|
@ribbon_action(
|
||||||
@ -213,8 +255,6 @@ class FileOperationsMixin:
|
|||||||
)
|
)
|
||||||
def save_project(self):
|
def save_project(self):
|
||||||
"""Save the current project"""
|
"""Save the current project"""
|
||||||
from pyPhotoAlbum.project_serializer import save_to_zip
|
|
||||||
|
|
||||||
file_path, _ = QFileDialog.getSaveFileName(
|
file_path, _ = QFileDialog.getSaveFileName(
|
||||||
self,
|
self,
|
||||||
"Save Project",
|
"Save Project",
|
||||||
@ -244,8 +284,6 @@ class FileOperationsMixin:
|
|||||||
)
|
)
|
||||||
def heal_assets(self):
|
def heal_assets(self):
|
||||||
"""Open the asset healing dialog to reconnect missing images"""
|
"""Open the asset healing dialog to reconnect missing images"""
|
||||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
|
||||||
|
|
||||||
dialog = AssetHealDialog(self.project, self)
|
dialog = AssetHealDialog(self.project, self)
|
||||||
dialog.exec()
|
dialog.exec()
|
||||||
|
|
||||||
@ -529,9 +567,6 @@ class FileOperationsMixin:
|
|||||||
)
|
)
|
||||||
def show_about(self):
|
def show_about(self):
|
||||||
"""Show about dialog with version information"""
|
"""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 = QDialog(self)
|
||||||
dialog.setWindowTitle("About pyPhotoAlbum")
|
dialog.setWindowTitle("About pyPhotoAlbum")
|
||||||
dialog.setMinimumWidth(600)
|
dialog.setMinimumWidth(600)
|
||||||
|
|||||||
@ -494,11 +494,8 @@ class PDFExporter:
|
|||||||
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)
|
Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
# Apply rotation if needed
|
# Note: Rotation is applied at the canvas level (below), not here
|
||||||
if image_element.rotation != 0:
|
# to avoid double-rotation issues
|
||||||
# Rotate around center
|
|
||||||
cropped_img = cropped_img.rotate(-image_element.rotation, expand=True,
|
|
||||||
fillcolor=(255, 255, 255, 0))
|
|
||||||
|
|
||||||
# Save state for transformations
|
# Save state for transformations
|
||||||
c.saveState()
|
c.saveState()
|
||||||
@ -509,7 +506,7 @@ class PDFExporter:
|
|||||||
center_x = x_pt + width_pt / 2
|
center_x = x_pt + width_pt / 2
|
||||||
center_y = y_pt + height_pt / 2
|
center_y = y_pt + height_pt / 2
|
||||||
c.translate(center_x, center_y)
|
c.translate(center_x, center_y)
|
||||||
c.rotate(image_element.rotation)
|
c.rotate(-image_element.rotation)
|
||||||
c.translate(-width_pt / 2, -height_pt / 2)
|
c.translate(-width_pt / 2, -height_pt / 2)
|
||||||
# Draw at origin after transformation
|
# Draw at origin after transformation
|
||||||
c.drawImage(ImageReader(cropped_img), 0, 0, width_pt, height_pt,
|
c.drawImage(ImageReader(cropped_img), 0, 0, width_pt, height_pt,
|
||||||
|
|||||||
116
test_loading_widget.py
Normal file
116
test_loading_widget.py
Normal file
@ -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()
|
||||||
Loading…
x
Reference in New Issue
Block a user