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()
|
||||
@ -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"""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
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