Compare commits
2 Commits
7d8d2d42f8
...
950aa6bee9
| Author | SHA1 | Date | |
|---|---|---|---|
| 950aa6bee9 | |||
| 1b0e6f9ed1 |
@ -354,12 +354,16 @@ class AsyncImageLoader(QObject):
|
|||||||
if img.mode != 'RGBA':
|
if img.mode != 'RGBA':
|
||||||
img = img.convert('RGBA')
|
img = img.convert('RGBA')
|
||||||
|
|
||||||
# Downsample if target size specified
|
# Downsample if target size specified (preserving aspect ratio)
|
||||||
if target_size:
|
if target_size:
|
||||||
current_size = img.size
|
max_size = target_size[0] # Assume square target (2048, 2048)
|
||||||
if current_size[0] > target_size[0] or current_size[1] > target_size[1]:
|
if img.width > max_size or img.height > max_size:
|
||||||
img = img.resize(target_size, Image.Resampling.LANCZOS)
|
# Calculate scale to fit within max_size while preserving aspect ratio
|
||||||
logger.debug(f"Downsampled {path}: {current_size} -> {target_size}")
|
scale = min(max_size / img.width, max_size / img.height)
|
||||||
|
new_width = int(img.width * scale)
|
||||||
|
new_height = int(img.height * scale)
|
||||||
|
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||||
|
logger.debug(f"Downsampled {path}: {img.size} -> ({new_width}, {new_height})")
|
||||||
|
|
||||||
return img
|
return img
|
||||||
|
|
||||||
|
|||||||
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,23 +191,60 @@ class FileOperationsMixin:
|
|||||||
if file_path:
|
if file_path:
|
||||||
print(f"Opening project: {file_path}")
|
print(f"Opening project: {file_path}")
|
||||||
|
|
||||||
# Cleanup old project if it exists
|
# Create loading widget if not exists
|
||||||
if hasattr(self, 'project') and self.project:
|
if not hasattr(self, '_loading_widget'):
|
||||||
self.project.cleanup()
|
self._loading_widget = LoadingWidget(self)
|
||||||
|
|
||||||
# Load project from ZIP
|
# Show loading widget
|
||||||
project, error = load_from_zip(file_path)
|
self._loading_widget.show_loading("Opening project...")
|
||||||
|
|
||||||
if project:
|
# Create and configure async loader
|
||||||
self.project = project
|
self._project_loader = AsyncProjectLoader(file_path)
|
||||||
self.current_page_index = 0 # Reset to first page
|
|
||||||
self.update_view()
|
# Connect signals
|
||||||
self.show_status(f"Project opened: {file_path}")
|
self._project_loader.progress_updated.connect(self._on_load_progress)
|
||||||
print(f"Successfully loaded project: {project.name}")
|
self._project_loader.load_complete.connect(self._on_load_complete)
|
||||||
else:
|
self._project_loader.load_failed.connect(self._on_load_failed)
|
||||||
error_msg = f"Failed to open project: {error}"
|
|
||||||
self.show_status(error_msg)
|
# Start async loading
|
||||||
print(error_msg)
|
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(
|
@ribbon_action(
|
||||||
label="Save",
|
label="Save",
|
||||||
@ -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)
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from abc import ABC, abstractmethod
|
|||||||
from typing import Tuple, Optional, Dict, Any, List
|
from typing import Tuple, Optional, Dict, Any, List
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
# Global configuration for asset path resolution
|
# Global configuration for asset path resolution
|
||||||
_asset_search_paths: List[str] = []
|
_asset_search_paths: List[str] = []
|
||||||
@ -57,15 +58,55 @@ class BaseLayoutElement(ABC):
|
|||||||
class ImageData(BaseLayoutElement):
|
class ImageData(BaseLayoutElement):
|
||||||
"""Class to store image data and properties"""
|
"""Class to store image data and properties"""
|
||||||
|
|
||||||
def __init__(self, image_path: str = "", crop_info: Optional[Tuple] = None, **kwargs):
|
def __init__(self, image_path: str = "", crop_info: Optional[Tuple] = None,
|
||||||
|
image_dimensions: Optional[Tuple[int, int]] = None, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.image_path = image_path
|
self.image_path = image_path
|
||||||
self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop
|
self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop
|
||||||
|
|
||||||
|
# Metadata: Store image dimensions for aspect ratio calculations before full load
|
||||||
|
# This allows correct rendering even while async loading is in progress
|
||||||
|
self.image_dimensions = image_dimensions # (width, height) or None
|
||||||
|
|
||||||
|
# If dimensions not provided and we have a path, try to extract them quickly
|
||||||
|
if not self.image_dimensions and self.image_path:
|
||||||
|
self._extract_dimensions_metadata()
|
||||||
|
|
||||||
# Async loading state
|
# Async loading state
|
||||||
self._async_loading = False
|
self._async_loading = False
|
||||||
self._async_load_requested = False
|
self._async_load_requested = False
|
||||||
|
|
||||||
|
def _extract_dimensions_metadata(self):
|
||||||
|
"""
|
||||||
|
Extract image dimensions without loading the full image.
|
||||||
|
Uses PIL's lazy loading to just read the header.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Resolve path
|
||||||
|
image_path = self.image_path
|
||||||
|
if not os.path.isabs(self.image_path):
|
||||||
|
project_folder, search_paths = get_asset_search_paths()
|
||||||
|
if project_folder and os.path.exists(os.path.join(project_folder, self.image_path)):
|
||||||
|
image_path = os.path.join(project_folder, self.image_path)
|
||||||
|
|
||||||
|
if os.path.exists(image_path):
|
||||||
|
# Use PIL to just read dimensions (fast, doesn't load pixel data)
|
||||||
|
with Image.open(image_path) as img:
|
||||||
|
width, height = img.width, img.height
|
||||||
|
|
||||||
|
# Apply same downsampling logic as the old sync code (max 2048px)
|
||||||
|
max_size = 2048
|
||||||
|
if width > max_size or height > max_size:
|
||||||
|
scale = min(max_size / width, max_size / height)
|
||||||
|
width = int(width * scale)
|
||||||
|
height = int(height * scale)
|
||||||
|
|
||||||
|
self.image_dimensions = (width, height)
|
||||||
|
print(f"ImageData: Extracted dimensions {self.image_dimensions} for {self.image_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ImageData: Could not extract dimensions for {self.image_path}: {e}")
|
||||||
|
self.image_dimensions = None
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
"""Render the image using OpenGL"""
|
"""Render the image using OpenGL"""
|
||||||
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP,
|
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP,
|
||||||
@ -126,97 +167,71 @@ class ImageData(BaseLayoutElement):
|
|||||||
print(f"ImageData: Could not resolve path: {self.image_path}")
|
print(f"ImageData: Could not resolve path: {self.image_path}")
|
||||||
print(f" Tried paths: {possible_paths[:3]}") # Print first 3 to avoid clutter
|
print(f" Tried paths: {possible_paths[:3]}") # Print first 3 to avoid clutter
|
||||||
|
|
||||||
if image_full_path and os.path.exists(image_full_path):
|
# NOTE: Async loading is now handled by page_layout.py calling request_image_load()
|
||||||
# Check if we already have a cached texture
|
# This sync path should only be reached if async loading is not available
|
||||||
if not hasattr(self, '_texture_id') or not hasattr(self, '_texture_path') or self._texture_path != image_full_path:
|
# The actual image will be loaded in the background and the texture created
|
||||||
try:
|
# via _on_async_image_loaded() callback when ready
|
||||||
# Load image
|
|
||||||
img = Image.open(image_full_path)
|
|
||||||
img = img.convert('RGBA')
|
|
||||||
|
|
||||||
# Scale down large images for performance (max 2048px on longest side)
|
# Use cached texture if available
|
||||||
max_size = 2048
|
if hasattr(self, '_texture_id') and self._texture_id:
|
||||||
if img.width > max_size or img.height > max_size:
|
texture_id = self._texture_id
|
||||||
scale = min(max_size / img.width, max_size / img.height)
|
|
||||||
new_width = int(img.width * scale)
|
|
||||||
new_height = int(img.height * scale)
|
|
||||||
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
img_data = img.tobytes()
|
# Get image dimensions (from loaded texture or metadata)
|
||||||
|
if hasattr(self, '_img_width') and hasattr(self, '_img_height'):
|
||||||
|
img_width, img_height = self._img_width, self._img_height
|
||||||
|
elif self.image_dimensions:
|
||||||
|
img_width, img_height = self.image_dimensions
|
||||||
|
else:
|
||||||
|
# No dimensions available, render without aspect ratio correction
|
||||||
|
img_width, img_height = int(w), int(h)
|
||||||
|
|
||||||
# Delete old texture if it exists
|
# Get crop info
|
||||||
if hasattr(self, '_texture_id') and self._texture_id:
|
crop_x_min, crop_y_min, crop_x_max, crop_y_max = self.crop_info
|
||||||
glDeleteTextures([self._texture_id])
|
|
||||||
|
|
||||||
# Create texture
|
# Calculate aspect ratios for center crop
|
||||||
texture_id = glGenTextures(1)
|
img_aspect = img_width / img_height
|
||||||
glBindTexture(GL_TEXTURE_2D, texture_id)
|
target_aspect = w / h
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
|
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
|
|
||||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, img.width, img.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data)
|
|
||||||
|
|
||||||
# Cache texture ID and path
|
# Calculate texture coordinates for center crop
|
||||||
self._texture_id = texture_id
|
if img_aspect > target_aspect:
|
||||||
self._texture_path = image_full_path
|
# Image is wider - crop horizontally
|
||||||
self._img_width = img.width
|
scale = target_aspect / img_aspect
|
||||||
self._img_height = img.height
|
tx_offset = (1.0 - scale) / 2.0
|
||||||
|
tx_min_base = tx_offset
|
||||||
|
tx_max_base = 1.0 - tx_offset
|
||||||
|
ty_min_base = 0.0
|
||||||
|
ty_max_base = 1.0
|
||||||
|
else:
|
||||||
|
# Image is taller - crop vertically
|
||||||
|
scale = img_aspect / target_aspect
|
||||||
|
ty_offset = (1.0 - scale) / 2.0
|
||||||
|
tx_min_base = 0.0
|
||||||
|
tx_max_base = 1.0
|
||||||
|
ty_min_base = ty_offset
|
||||||
|
ty_max_base = 1.0 - ty_offset
|
||||||
|
|
||||||
except Exception as e:
|
# Apply additional crop from crop_info (for spanning elements)
|
||||||
print(f"Error loading image {image_full_path}: {e}")
|
tx_range = tx_max_base - tx_min_base
|
||||||
print(f" Original path: {self.image_path}")
|
ty_range = ty_max_base - ty_min_base
|
||||||
self._texture_id = None
|
|
||||||
|
|
||||||
# Use cached texture
|
tx_min = tx_min_base + crop_x_min * tx_range
|
||||||
if hasattr(self, '_texture_id') and self._texture_id:
|
tx_max = tx_min_base + crop_x_max * tx_range
|
||||||
texture_id = self._texture_id
|
ty_min = ty_min_base + crop_y_min * ty_range
|
||||||
|
ty_max = ty_min_base + crop_y_max * ty_range
|
||||||
|
|
||||||
# Get crop info
|
# Enable texturing and draw with crop
|
||||||
crop_x_min, crop_y_min, crop_x_max, crop_y_max = self.crop_info
|
glEnable(GL_TEXTURE_2D)
|
||||||
|
glBindTexture(GL_TEXTURE_2D, texture_id)
|
||||||
|
glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is
|
||||||
|
|
||||||
# Calculate aspect ratios for center crop
|
glBegin(GL_QUADS)
|
||||||
img_aspect = self._img_width / self._img_height
|
glTexCoord2f(tx_min, ty_min); glVertex2f(x, y)
|
||||||
target_aspect = w / h
|
glTexCoord2f(tx_max, ty_min); glVertex2f(x + w, y)
|
||||||
|
glTexCoord2f(tx_max, ty_max); glVertex2f(x + w, y + h)
|
||||||
|
glTexCoord2f(tx_min, ty_max); glVertex2f(x, y + h)
|
||||||
|
glEnd()
|
||||||
|
|
||||||
# Calculate texture coordinates for center crop
|
glDisable(GL_TEXTURE_2D)
|
||||||
if img_aspect > target_aspect:
|
|
||||||
# Image is wider - crop horizontally
|
|
||||||
scale = target_aspect / img_aspect
|
|
||||||
tx_offset = (1.0 - scale) / 2.0
|
|
||||||
tx_min_base = tx_offset
|
|
||||||
tx_max_base = 1.0 - tx_offset
|
|
||||||
ty_min_base = 0.0
|
|
||||||
ty_max_base = 1.0
|
|
||||||
else:
|
|
||||||
# Image is taller - crop vertically
|
|
||||||
scale = img_aspect / target_aspect
|
|
||||||
ty_offset = (1.0 - scale) / 2.0
|
|
||||||
tx_min_base = 0.0
|
|
||||||
tx_max_base = 1.0
|
|
||||||
ty_min_base = ty_offset
|
|
||||||
ty_max_base = 1.0 - ty_offset
|
|
||||||
|
|
||||||
# Apply additional crop from crop_info (for spanning elements)
|
|
||||||
tx_range = tx_max_base - tx_min_base
|
|
||||||
ty_range = ty_max_base - ty_min_base
|
|
||||||
|
|
||||||
tx_min = tx_min_base + crop_x_min * tx_range
|
|
||||||
tx_max = tx_min_base + crop_x_max * tx_range
|
|
||||||
ty_min = ty_min_base + crop_y_min * ty_range
|
|
||||||
ty_max = ty_min_base + crop_y_max * ty_range
|
|
||||||
|
|
||||||
# Enable texturing and draw with crop
|
|
||||||
glEnable(GL_TEXTURE_2D)
|
|
||||||
glBindTexture(GL_TEXTURE_2D, texture_id)
|
|
||||||
glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is
|
|
||||||
|
|
||||||
glBegin(GL_QUADS)
|
|
||||||
glTexCoord2f(tx_min, ty_min); glVertex2f(x, y)
|
|
||||||
glTexCoord2f(tx_max, ty_min); glVertex2f(x + w, y)
|
|
||||||
glTexCoord2f(tx_max, ty_max); glVertex2f(x + w, y + h)
|
|
||||||
glTexCoord2f(tx_min, ty_max); glVertex2f(x, y + h)
|
|
||||||
glEnd()
|
|
||||||
|
|
||||||
glDisable(GL_TEXTURE_2D)
|
|
||||||
|
|
||||||
# If no image or loading failed, draw placeholder
|
# If no image or loading failed, draw placeholder
|
||||||
if not texture_id:
|
if not texture_id:
|
||||||
@ -243,7 +258,7 @@ class ImageData(BaseLayoutElement):
|
|||||||
|
|
||||||
def serialize(self) -> Dict[str, Any]:
|
def serialize(self) -> Dict[str, Any]:
|
||||||
"""Serialize image data to dictionary"""
|
"""Serialize image data to dictionary"""
|
||||||
return {
|
data = {
|
||||||
"type": "image",
|
"type": "image",
|
||||||
"position": self.position,
|
"position": self.position,
|
||||||
"size": self.size,
|
"size": self.size,
|
||||||
@ -252,6 +267,10 @@ class ImageData(BaseLayoutElement):
|
|||||||
"image_path": self.image_path,
|
"image_path": self.image_path,
|
||||||
"crop_info": self.crop_info
|
"crop_info": self.crop_info
|
||||||
}
|
}
|
||||||
|
# Include image dimensions metadata if available
|
||||||
|
if self.image_dimensions:
|
||||||
|
data["image_dimensions"] = self.image_dimensions
|
||||||
|
return data
|
||||||
|
|
||||||
def deserialize(self, data: Dict[str, Any]):
|
def deserialize(self, data: Dict[str, Any]):
|
||||||
"""Deserialize from dictionary"""
|
"""Deserialize from dictionary"""
|
||||||
@ -261,6 +280,10 @@ class ImageData(BaseLayoutElement):
|
|||||||
self.z_index = data.get("z_index", 0)
|
self.z_index = data.get("z_index", 0)
|
||||||
self.image_path = data.get("image_path", "")
|
self.image_path = data.get("image_path", "")
|
||||||
self.crop_info = tuple(data.get("crop_info", (0, 0, 1, 1)))
|
self.crop_info = tuple(data.get("crop_info", (0, 0, 1, 1)))
|
||||||
|
# Load image dimensions metadata if available
|
||||||
|
self.image_dimensions = data.get("image_dimensions", None)
|
||||||
|
if self.image_dimensions:
|
||||||
|
self.image_dimensions = tuple(self.image_dimensions)
|
||||||
|
|
||||||
def _on_async_image_loaded(self, pil_image):
|
def _on_async_image_loaded(self, pil_image):
|
||||||
"""
|
"""
|
||||||
@ -295,6 +318,10 @@ class ImageData(BaseLayoutElement):
|
|||||||
self._img_height = pil_image.height
|
self._img_height = pil_image.height
|
||||||
self._async_loading = False
|
self._async_loading = False
|
||||||
|
|
||||||
|
# Update metadata for future renders
|
||||||
|
if not self.image_dimensions:
|
||||||
|
self.image_dimensions = (pil_image.width, pil_image.height)
|
||||||
|
|
||||||
print(f"ImageData: Async loaded texture for {self.image_path}")
|
print(f"ImageData: Async loaded texture for {self.image_path}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -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