black formatting
All checks were successful
Python CI / test (push) Successful in 1m20s
Lint / lint (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m27s
Tests / test (3.12) (push) Successful in 2m25s
Tests / test (3.13) (push) Successful in 2m52s
Tests / test (3.14) (push) Successful in 1m9s

This commit is contained in:
Duncan Tourolle 2025-11-27 23:07:16 +01:00
parent 3b2301b547
commit f6ed11b0bc
107 changed files with 6940 additions and 4410 deletions

View File

@ -65,14 +65,10 @@ class ElementMaximizer:
# Calculate distances between rectangles
horizontal_gap = max(
other_x - (x + w), # Other is to the right
x - (other_x + other_w) # Other is to the left
other_x - (x + w), x - (other_x + other_w) # Other is to the right # Other is to the left
)
vertical_gap = max(
other_y - (y + h), # Other is below
y - (other_y + other_h) # Other is above
)
vertical_gap = max(other_y - (y + h), y - (other_y + other_h)) # Other is below # Other is above
# If rectangles overlap or are too close in both dimensions
if horizontal_gap < self.min_gap and vertical_gap < self.min_gap:
@ -80,8 +76,14 @@ class ElementMaximizer:
return False
def find_max_scale(self, elem_idx: int, current_scale: float, max_search_scale: float = 3.0,
tolerance: float = 0.001, max_iterations: int = 20) -> float:
def find_max_scale(
self,
elem_idx: int,
current_scale: float,
max_search_scale: float = 3.0,
tolerance: float = 0.001,
max_iterations: int = 20,
) -> float:
"""
Use binary search to find the maximum scale factor for an element.
@ -171,8 +173,10 @@ class ElementMaximizer:
ow, oh = other.size
# Check if rectangles overlap (with min_gap consideration)
if (abs((x + w/2) - (ox + ow/2)) < (w + ow)/2 + self.min_gap and
abs((y + h/2) - (oy + oh/2)) < (h + oh)/2 + self.min_gap):
if (
abs((x + w / 2) - (ox + ow / 2)) < (w + ow) / 2 + self.min_gap
and abs((y + h / 2) - (oy + oh / 2)) < (h + oh) / 2 + self.min_gap
):
return True
return False
@ -233,7 +237,9 @@ class ElementMaximizer:
self.center_element_horizontally(elem)
self.center_element_vertically(elem)
def maximize(self, max_iterations: int = 100, growth_rate: float = 0.05) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
def maximize(
self, max_iterations: int = 100, growth_rate: float = 0.05
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
"""
Execute the maximization algorithm.
@ -365,7 +371,9 @@ class AlignmentManager:
return changes
@staticmethod
def align_horizontal_center(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
def align_horizontal_center(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
"""
Align all elements to horizontal center.
@ -413,7 +421,9 @@ class AlignmentManager:
return changes
@staticmethod
def make_same_size(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
def make_same_size(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
"""
Make all elements the same size as the first element.
@ -435,7 +445,9 @@ class AlignmentManager:
return changes
@staticmethod
def make_same_width(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
def make_same_width(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
"""
Make all elements the same width as the first element.
@ -457,7 +469,9 @@ class AlignmentManager:
return changes
@staticmethod
def make_same_height(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
def make_same_height(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
"""
Make all elements the same height as the first element.
@ -479,7 +493,9 @@ class AlignmentManager:
return changes
@staticmethod
def distribute_horizontally(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
def distribute_horizontally(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
"""
Distribute elements evenly across horizontal span.
@ -613,7 +629,9 @@ class AlignmentManager:
return changes
@staticmethod
def fit_to_page_width(element: BaseLayoutElement, page_width: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
def fit_to_page_width(
element: BaseLayoutElement, page_width: float
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
"""
Resize element to fit page width while maintaining aspect ratio.
@ -638,7 +656,9 @@ class AlignmentManager:
return (element, old_pos, old_size)
@staticmethod
def fit_to_page_height(element: BaseLayoutElement, page_height: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
def fit_to_page_height(
element: BaseLayoutElement, page_height: float
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
"""
Resize element to fit page height while maintaining aspect ratio.
@ -663,7 +683,9 @@ class AlignmentManager:
return (element, old_pos, old_size)
@staticmethod
def fit_to_page(element: BaseLayoutElement, page_width: float, page_height: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
def fit_to_page(
element: BaseLayoutElement, page_width: float, page_height: float
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
"""
Resize element to fit within page dimensions while maintaining aspect ratio.
@ -702,7 +724,7 @@ class AlignmentManager:
page_size: Tuple[float, float],
min_gap: float = 2.0,
max_iterations: int = 100,
growth_rate: float = 0.05
growth_rate: float = 0.05,
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
"""
Maximize element sizes using a crystal growth algorithm.
@ -729,7 +751,7 @@ class AlignmentManager:
element: BaseLayoutElement,
page_size: Tuple[float, float],
other_elements: List[BaseLayoutElement],
min_gap: float = 10.0
min_gap: float = 10.0,
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
"""
Expand a single element until it is min_gap away from page edges or other elements.

View File

@ -6,9 +6,16 @@ import os
import shutil
from typing import List, Dict, Set
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QListWidget, QListWidgetItem, QFileDialog, QGroupBox,
QMessageBox
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QListWidget,
QListWidgetItem,
QFileDialog,
QGroupBox,
QMessageBox,
)
from PyQt6.QtCore import Qt
@ -124,10 +131,7 @@ class AssetHealDialog(QDialog):
def _add_search_path(self):
"""Add a search path"""
directory = QFileDialog.getExistingDirectory(
self,
"Select Search Path for Assets",
"",
QFileDialog.Option.ShowDirsOnly
self, "Select Search Path for Assets", "", QFileDialog.Option.ShowDirsOnly
)
if directory:
@ -203,7 +207,7 @@ class AssetHealDialog(QDialog):
# Check if the found file needs to be imported
# (i.e., it's not already in the assets folder)
needs_import = True
if not os.path.isabs(asset_path) and asset_path.startswith('assets/'):
if not os.path.isabs(asset_path) and asset_path.startswith("assets/"):
# It's already a relative assets path, just missing from disk
# Copy it to the correct location
dest_path = os.path.join(self.project.folder_path, asset_path)

View File

@ -143,9 +143,7 @@ class AssetManager:
def serialize(self) -> Dict:
"""Serialize asset manager state"""
return {
"reference_counts": self.reference_counts
}
return {"reference_counts": self.reference_counts}
def deserialize(self, data: Dict):
"""Deserialize asset manager state"""

View File

@ -28,16 +28,14 @@ logger = logging.getLogger(__name__)
class LoadPriority(Enum):
"""Priority levels for load requests."""
LOW = 0 # Offscreen, not visible
NORMAL = 1 # Potentially visible soon
HIGH = 2 # Visible on screen
URGENT = 3 # User is actively interacting with
LOW = 0 # Offscreen, not visible
NORMAL = 1 # Potentially visible soon
HIGH = 2 # Visible on screen
URGENT = 3 # User is actively interacting with
def get_image_dimensions(
image_path: str,
max_size: Optional[int] = None
) -> Optional[Tuple[int, int]]:
def get_image_dimensions(image_path: str, max_size: Optional[int] = None) -> Optional[Tuple[int, int]]:
"""
Extract image dimensions without loading the full image.
@ -78,6 +76,7 @@ def get_image_dimensions(
@dataclass(order=True)
class LoadRequest:
"""Request to load and process an image."""
priority: LoadPriority = field(compare=True)
request_id: int = field(compare=True) # Tie-breaker for same priority
path: Path = field(compare=False)
@ -111,7 +110,7 @@ class ImageCache:
"""Estimate memory size of PIL image in bytes."""
# PIL images are typically width * height * bytes_per_pixel
# RGBA = 4 bytes, RGB = 3 bytes, L = 1 byte
mode_sizes = {'RGBA': 4, 'RGB': 3, 'L': 1, 'LA': 2}
mode_sizes = {"RGBA": 4, "RGB": 3, "L": 1, "LA": 2}
bytes_per_pixel = mode_sizes.get(img.mode, 4)
return img.width * img.height * bytes_per_pixel
@ -164,8 +163,7 @@ class ImageCache:
self.current_memory_bytes -= old_size
# Evict LRU items if needed
while (self.current_memory_bytes + img_size > self.max_memory_bytes
and len(self._cache) > 0):
while self.current_memory_bytes + img_size > self.max_memory_bytes and len(self._cache) > 0:
evicted_key, (evicted_img, evicted_size) = self._cache.popitem(last=False)
self.current_memory_bytes -= evicted_size
logger.debug(f"Cache EVICT: {evicted_key} ({evicted_size / 1024 / 1024:.1f}MB)")
@ -174,10 +172,12 @@ class ImageCache:
self._cache[key] = (img.copy(), img_size)
self.current_memory_bytes += img_size
logger.debug(f"Cache PUT: {key} ({img_size / 1024 / 1024:.1f}MB) "
f"[Total: {self.current_memory_bytes / 1024 / 1024:.1f}MB / "
f"{self.max_memory_bytes / 1024 / 1024:.1f}MB, "
f"Items: {len(self._cache)}]")
logger.debug(
f"Cache PUT: {key} ({img_size / 1024 / 1024:.1f}MB) "
f"[Total: {self.current_memory_bytes / 1024 / 1024:.1f}MB / "
f"{self.max_memory_bytes / 1024 / 1024:.1f}MB, "
f"Items: {len(self._cache)}]"
)
def clear(self):
"""Clear entire cache."""
@ -190,10 +190,10 @@ class ImageCache:
"""Get cache statistics."""
with self._lock:
return {
'items': len(self._cache),
'memory_mb': self.current_memory_bytes / 1024 / 1024,
'max_memory_mb': self.max_memory_bytes / 1024 / 1024,
'utilization': (self.current_memory_bytes / self.max_memory_bytes) * 100
"items": len(self._cache),
"memory_mb": self.current_memory_bytes / 1024 / 1024,
"max_memory_mb": self.max_memory_bytes / 1024 / 1024,
"utilization": (self.current_memory_bytes / self.max_memory_bytes) * 100,
}
@ -213,7 +213,7 @@ class AsyncImageLoader(QObject):
# Signals for Qt integration
image_loaded = pyqtSignal(object, object, object) # (path, image, user_data)
load_failed = pyqtSignal(object, str, object) # (path, error_msg, user_data)
load_failed = pyqtSignal(object, str, object) # (path, error_msg, user_data)
def __init__(self, cache: Optional[ImageCache] = None, max_workers: int = 4):
"""
@ -227,8 +227,7 @@ class AsyncImageLoader(QObject):
self.cache = cache or ImageCache()
self.max_workers = max_workers
self.executor = ThreadPoolExecutor(max_workers=max_workers,
thread_name_prefix="ImageLoader")
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="ImageLoader")
# Priority queue and tracking
self._queue: asyncio.PriorityQueue = None # Created when event loop starts
@ -251,9 +250,9 @@ class AsyncImageLoader(QObject):
return
self._shutdown = False
self._loop_thread = threading.Thread(target=self._run_event_loop,
daemon=True,
name="AsyncImageLoader-EventLoop")
self._loop_thread = threading.Thread(
target=self._run_event_loop, daemon=True, name="AsyncImageLoader-EventLoop"
)
self._loop_thread.start()
logger.info("AsyncImageLoader event loop started")
@ -354,12 +353,7 @@ class AsyncImageLoader(QObject):
# Load in thread pool (I/O bound)
loop = asyncio.get_event_loop()
img = await loop.run_in_executor(
self.executor,
self._load_and_process_image,
path,
target_size
)
img = await loop.run_in_executor(self.executor, self._load_and_process_image, path, target_size)
# Cache result
self.cache.put(path, img, target_size)
@ -411,11 +405,13 @@ class AsyncImageLoader(QObject):
"""Emit load_failed signal (thread-safe)."""
self.load_failed.emit(path, error_msg, user_data)
def request_load(self,
path: Path,
priority: LoadPriority = LoadPriority.NORMAL,
target_size: Optional[Tuple[int, int]] = None,
user_data: Any = None) -> bool:
def request_load(
self,
path: Path,
priority: LoadPriority = LoadPriority.NORMAL,
target_size: Optional[Tuple[int, int]] = None,
user_data: Any = None,
) -> bool:
"""
Request image load with specified priority.
@ -446,7 +442,7 @@ class AsyncImageLoader(QObject):
request_id=self._next_request_id,
path=path,
target_size=target_size,
user_data=user_data
user_data=user_data,
)
self._next_request_id += 1
@ -454,10 +450,7 @@ class AsyncImageLoader(QObject):
self._pending_requests[path] = request
# Submit to queue (thread-safe)
asyncio.run_coroutine_threadsafe(
self._queue.put(request),
self._loop
)
asyncio.run_coroutine_threadsafe(self._queue.put(request), self._loop)
logger.debug(f"Queued load: {path} (priority: {priority.name})")
return True
@ -494,9 +487,9 @@ class AsyncImageLoader(QObject):
"""Get loader statistics."""
with self._lock:
return {
'pending': len(self._pending_requests),
'active': len(self._active_tasks),
'cache': self.cache.get_stats()
"pending": len(self._pending_requests),
"active": len(self._active_tasks),
"cache": self.cache.get_stats(),
}
@ -517,8 +510,8 @@ class AsyncPDFGenerator(QObject):
# Signals for Qt integration
progress_updated = pyqtSignal(int, int, str) # (current, total, message)
export_complete = pyqtSignal(bool, list) # (success, warnings)
export_failed = pyqtSignal(str) # (error_message)
export_complete = pyqtSignal(bool, list) # (success, warnings)
export_failed = pyqtSignal(str) # (error_message)
def __init__(self, image_cache: Optional[ImageCache] = None, max_workers: int = 2):
"""
@ -532,8 +525,7 @@ class AsyncPDFGenerator(QObject):
self.image_cache = image_cache or ImageCache()
self.max_workers = max_workers
self.executor = ThreadPoolExecutor(max_workers=max_workers,
thread_name_prefix="PDFGenerator")
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="PDFGenerator")
# Export state
self._current_export: Optional[asyncio.Task] = None
@ -554,9 +546,9 @@ class AsyncPDFGenerator(QObject):
return
self._shutdown = False
self._loop_thread = threading.Thread(target=self._run_event_loop,
daemon=True,
name="AsyncPDFGenerator-EventLoop")
self._loop_thread = threading.Thread(
target=self._run_event_loop, daemon=True, name="AsyncPDFGenerator-EventLoop"
)
self._loop_thread.start()
logger.info("AsyncPDFGenerator event loop started")
@ -621,8 +613,7 @@ class AsyncPDFGenerator(QObject):
# Submit export task
self._current_export = asyncio.run_coroutine_threadsafe(
self._export_pdf_async(project, output_path, export_dpi),
self._loop
self._export_pdf_async(project, output_path, export_dpi), self._loop
)
logger.info(f"PDF export started: {output_path}")
@ -662,11 +653,7 @@ class AsyncPDFGenerator(QObject):
# Run export in thread pool
loop = asyncio.get_event_loop()
success, warnings = await loop.run_in_executor(
self.executor,
self._export_with_cache,
exporter,
output_path,
progress_callback
self.executor, self._export_with_cache, exporter, output_path, progress_callback
)
# Emit completion signal
@ -731,13 +718,9 @@ class AsyncPDFGenerator(QObject):
def is_exporting(self) -> bool:
"""Check if export is currently in progress."""
with self._lock:
return (self._current_export is not None
and not self._current_export.done())
return self._current_export is not None and not self._current_export.done()
def get_stats(self) -> Dict[str, Any]:
"""Get generator statistics."""
with self._lock:
return {
'exporting': self.is_exporting(),
'cache': self.image_cache.get_stats()
}
return {"exporting": self.is_exporting(), "cache": self.image_cache.get_stats()}

View File

@ -18,7 +18,7 @@ from pyPhotoAlbum.version_manager import (
CURRENT_DATA_VERSION,
check_version_compatibility,
VersionCompatibility,
DataMigration
DataMigration,
)
@ -78,7 +78,7 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(10, 100, "Extracting project files...")
# Extract ZIP contents with progress
with zipfile.ZipFile(self.zip_path, 'r') as zipf:
with zipfile.ZipFile(self.zip_path, "r") as zipf:
file_list = zipf.namelist()
total_files = len(file_list)
@ -91,10 +91,7 @@ class AsyncProjectLoader(QThread):
# 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})"
)
self.progress_updated.emit(progress, 100, f"Extracting files... ({i + 1}/{total_files})")
if self._cancelled:
return
@ -103,12 +100,12 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(45, 100, "Loading project data...")
# Load project.json
project_json_path = os.path.join(extract_to, '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:
with open(project_json_path, "r") as f:
project_data = json.load(f)
if self._cancelled:
@ -118,7 +115,7 @@ class AsyncProjectLoader(QThread):
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'))
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:
@ -141,7 +138,7 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(70, 100, "Creating project...")
# Create new project
project_name = project_data.get('name', 'Untitled Project')
project_name = project_data.get("name", "Untitled Project")
project = Project(name=project_name, folder_path=extract_to)
# Deserialize project data
@ -197,14 +194,14 @@ class AsyncProjectLoader(QThread):
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/'):
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 "/assets/" in original_path:
parts = original_path.split("/assets/")
if len(parts) == 2:
new_path = os.path.join('assets', parts[1])
new_path = os.path.join("assets", parts[1])
element.image_path = new_path
normalized_count += 1
continue
@ -222,9 +219,9 @@ class AsyncProjectLoader(QThread):
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:
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.

View File

@ -86,11 +86,11 @@ class AutosaveManager:
"project_name": project.name,
"timestamp": datetime.now().isoformat(),
"checkpoint_path": str(checkpoint_path),
"original_path": getattr(project, 'file_path', None),
"original_path": getattr(project, "file_path", None),
}
metadata_path = checkpoint_path.with_suffix('.json')
with open(metadata_path, 'w') as f:
metadata_path = checkpoint_path.with_suffix(".json")
with open(metadata_path, "w") as f:
json.dump(metadata, f, indent=2)
def list_checkpoints(self, project_name: Optional[str] = None) -> List[Tuple[Path, dict]]:
@ -106,23 +106,23 @@ class AutosaveManager:
checkpoints = []
for checkpoint_file in self.CHECKPOINT_DIR.glob(f"{self.CHECKPOINT_PREFIX}*{self.CHECKPOINT_EXTENSION}"):
metadata_file = checkpoint_file.with_suffix('.json')
metadata_file = checkpoint_file.with_suffix(".json")
# Try to load metadata
metadata = {}
if metadata_file.exists():
try:
with open(metadata_file, 'r') as f:
with open(metadata_file, "r") as f:
metadata = json.load(f)
except:
pass
# Filter by project name if specified
if project_name is None or metadata.get('project_name') == project_name:
if project_name is None or metadata.get("project_name") == project_name:
checkpoints.append((checkpoint_file, metadata))
# Sort by timestamp (newest first)
checkpoints.sort(key=lambda x: x[1].get('timestamp', ''), reverse=True)
checkpoints.sort(key=lambda x: x[1].get("timestamp", ""), reverse=True)
return checkpoints
def load_checkpoint(self, checkpoint_path: Path):
@ -157,7 +157,7 @@ class AutosaveManager:
checkpoint_path.unlink()
# Delete metadata file
metadata_path = checkpoint_path.with_suffix('.json')
metadata_path = checkpoint_path.with_suffix(".json")
if metadata_path.exists():
metadata_path.unlink()
@ -190,7 +190,7 @@ class AutosaveManager:
# Group checkpoints by project
for checkpoint_path, metadata in self.list_checkpoints():
project_name = metadata.get('project_name', 'unknown')
project_name = metadata.get("project_name", "unknown")
if project_name not in checkpoints_by_project:
checkpoints_by_project[project_name] = []
checkpoints_by_project[project_name].append((checkpoint_path, metadata))
@ -198,11 +198,11 @@ class AutosaveManager:
# Clean up each project's checkpoints
for project_name, checkpoints in checkpoints_by_project.items():
# Sort by timestamp (newest first)
checkpoints.sort(key=lambda x: x[1].get('timestamp', ''), reverse=True)
checkpoints.sort(key=lambda x: x[1].get("timestamp", ""), reverse=True)
for idx, (checkpoint_path, metadata) in enumerate(checkpoints):
# Delete if too old
timestamp_str = metadata.get('timestamp')
timestamp_str = metadata.get("timestamp")
if timestamp_str:
try:
timestamp = datetime.fromisoformat(timestamp_str)

View File

@ -52,7 +52,7 @@ class Command(ABC):
@staticmethod
@abstractmethod
def deserialize(data: Dict[str, Any], project) -> 'Command':
def deserialize(data: Dict[str, Any], project) -> "Command":
"""Deserialize command from dictionary"""
pass
@ -89,14 +89,10 @@ class AddElementCommand(Command):
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "add_element",
"element": self.element.serialize(),
"executed": self.executed
}
return {"type": "add_element", "element": self.element.serialize(), "executed": self.executed}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'AddElementCommand':
def deserialize(data: Dict[str, Any], project) -> "AddElementCommand":
"""Deserialize from dictionary"""
# Reconstruct element from serialized data
elem_data = data["element"]
@ -152,14 +148,10 @@ class DeleteElementCommand(Command):
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "delete_element",
"element": self.element.serialize(),
"executed": self.executed
}
return {"type": "delete_element", "element": self.element.serialize(), "executed": self.executed}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'DeleteElementCommand':
def deserialize(data: Dict[str, Any], project) -> "DeleteElementCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
elem_type = elem_data.get("type")
@ -206,11 +198,11 @@ class MoveElementCommand(Command):
"type": "move_element",
"element": self.element.serialize(),
"old_position": self.old_position,
"new_position": self.new_position
"new_position": self.new_position,
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'MoveElementCommand':
def deserialize(data: Dict[str, Any], project) -> "MoveElementCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
elem_type = elem_data.get("type")
@ -226,18 +218,15 @@ class MoveElementCommand(Command):
element.deserialize(elem_data)
return MoveElementCommand(
element,
tuple(data["old_position"]),
tuple(data["new_position"])
)
return MoveElementCommand(element, tuple(data["old_position"]), tuple(data["new_position"]))
class ResizeElementCommand(Command):
"""Command for resizing an element"""
def __init__(self, element: BaseLayoutElement, old_position: tuple, old_size: tuple,
new_position: tuple, new_size: tuple):
def __init__(
self, element: BaseLayoutElement, old_position: tuple, old_size: tuple, new_position: tuple, new_size: tuple
):
self.element = element
self.old_position = old_position
self.old_size = old_size
@ -266,11 +255,11 @@ class ResizeElementCommand(Command):
"old_position": self.old_position,
"old_size": self.old_size,
"new_position": self.new_position,
"new_size": self.new_size
"new_size": self.new_size,
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'ResizeElementCommand':
def deserialize(data: Dict[str, Any], project) -> "ResizeElementCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
elem_type = elem_data.get("type")
@ -291,7 +280,7 @@ class ResizeElementCommand(Command):
tuple(data["old_position"]),
tuple(data["old_size"]),
tuple(data["new_position"]),
tuple(data["new_size"])
tuple(data["new_size"]),
)
@ -308,7 +297,7 @@ class RotateElementCommand(Command):
self.old_size = element.size
# For ImageData, store the old PIL rotation state
if hasattr(element, 'pil_rotation_90'):
if hasattr(element, "pil_rotation_90"):
self.old_pil_rotation = element.pil_rotation_90
else:
self.old_pil_rotation = None
@ -344,9 +333,9 @@ class RotateElementCommand(Command):
self.element.position = (center_x - h / 2, center_y - w / 2)
# Clear the texture so it will be reloaded with the new rotation
if hasattr(self.element, '_texture_id'):
if hasattr(self.element, "_texture_id"):
del self.element._texture_id
if hasattr(self.element, '_async_load_requested'):
if hasattr(self.element, "_async_load_requested"):
self.element._async_load_requested = False
# Keep visual rotation at 0
@ -376,7 +365,7 @@ class RotateElementCommand(Command):
# For ImageData, restore PIL rotation and clear texture
if isinstance(self.element, ImageData) and self.old_pil_rotation is not None:
self.element.pil_rotation_90 = self.old_pil_rotation
if hasattr(self.element, '_texture_id'):
if hasattr(self.element, "_texture_id"):
self.element._texture_id = None
self.element._async_load_requested = False
@ -390,11 +379,11 @@ class RotateElementCommand(Command):
"type": "rotate_element",
"element": self.element.serialize(),
"old_rotation": self.old_rotation,
"new_rotation": self.new_rotation
"new_rotation": self.new_rotation,
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'RotateElementCommand':
def deserialize(data: Dict[str, Any], project) -> "RotateElementCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
elem_type = elem_data.get("type")
@ -410,11 +399,7 @@ class RotateElementCommand(Command):
element.deserialize(elem_data)
return RotateElementCommand(
element,
data["old_rotation"],
data["new_rotation"]
)
return RotateElementCommand(element, data["old_rotation"], data["new_rotation"])
class AdjustImageCropCommand(Command):
@ -443,21 +428,17 @@ class AdjustImageCropCommand(Command):
"type": "adjust_image_crop",
"element": self.element.serialize(),
"old_crop_info": self.old_crop_info,
"new_crop_info": self.new_crop_info
"new_crop_info": self.new_crop_info,
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'AdjustImageCropCommand':
def deserialize(data: Dict[str, Any], project) -> "AdjustImageCropCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
element = ImageData()
element.deserialize(elem_data)
return AdjustImageCropCommand(
element,
tuple(data["old_crop_info"]),
tuple(data["new_crop_info"])
)
return AdjustImageCropCommand(element, tuple(data["old_crop_info"]), tuple(data["new_crop_info"]))
class AlignElementsCommand(Command):
@ -493,17 +474,11 @@ class AlignElementsCommand(Command):
"""Serialize to dictionary"""
return {
"type": "align_elements",
"changes": [
{
"element": elem.serialize(),
"old_position": old_pos
}
for elem, old_pos in self.changes
]
"changes": [{"element": elem.serialize(), "old_position": old_pos} for elem, old_pos in self.changes],
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'AlignElementsCommand':
def deserialize(data: Dict[str, Any], project) -> "AlignElementsCommand":
"""Deserialize from dictionary"""
changes = []
for change_data in data.get("changes", []):
@ -558,17 +533,13 @@ class ResizeElementsCommand(Command):
return {
"type": "resize_elements",
"changes": [
{
"element": elem.serialize(),
"old_position": old_pos,
"old_size": old_size
}
{"element": elem.serialize(), "old_position": old_pos, "old_size": old_size}
for elem, old_pos, old_size in self.changes
]
],
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'ResizeElementsCommand':
def deserialize(data: Dict[str, Any], project) -> "ResizeElementsCommand":
"""Deserialize from dictionary"""
changes = []
for change_data in data.get("changes", []):
@ -625,11 +596,11 @@ class ChangeZOrderCommand(Command):
"type": "change_zorder",
"element": self.element.serialize(),
"old_index": self.old_index,
"new_index": self.new_index
"new_index": self.new_index,
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'ChangeZOrderCommand':
def deserialize(data: Dict[str, Any], project) -> "ChangeZOrderCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
elem_type = elem_data.get("type")
@ -646,10 +617,7 @@ class ChangeZOrderCommand(Command):
element.deserialize(elem_data)
return ChangeZOrderCommand(
None, # page_layout will be set by CommandHistory
element,
data["old_index"],
data["new_index"]
None, element, data["old_index"], data["new_index"] # page_layout will be set by CommandHistory
)
@ -691,13 +659,10 @@ class StateChangeCommand(Command):
"""Serialize to dictionary"""
# For now, state change commands are not serialized
# This could be enhanced later if needed
return {
"type": "state_change",
"description": self.description
}
return {"type": "state_change", "description": self.description}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'StateChangeCommand':
def deserialize(data: Dict[str, Any], project) -> "StateChangeCommand":
"""Deserialize from dictionary"""
# Not implemented - would need to serialize state
raise NotImplementedError("StateChangeCommand deserialization not yet supported")
@ -801,7 +766,7 @@ class CommandHistory:
return {
"undo_stack": [cmd.serialize() for cmd in self.undo_stack],
"redo_stack": [cmd.serialize() for cmd in self.redo_stack],
"max_history": self.max_history
"max_history": self.max_history,
}
def deserialize(self, data: Dict[str, Any], project):

View File

@ -37,7 +37,7 @@ class RibbonAction:
shortcut: Optional[str] = None,
requires_page: bool = False,
requires_selection: bool = False,
min_selection: int = 0
min_selection: int = 0,
):
"""
Initialize the ribbon action decorator.
@ -73,22 +73,23 @@ class RibbonAction:
Returns:
The decorated function with metadata attached
"""
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
# Store metadata on wrapper function
wrapper._ribbon_action = {
'label': self.label,
'tooltip': self.tooltip,
'tab': self.tab,
'group': self.group,
'icon': self.icon,
'shortcut': self.shortcut,
'action': func.__name__,
'requires_page': self.requires_page,
'requires_selection': self.requires_selection,
'min_selection': self.min_selection
"label": self.label,
"tooltip": self.tooltip,
"tab": self.tab,
"group": self.group,
"icon": self.icon,
"shortcut": self.shortcut,
"action": func.__name__,
"requires_page": self.requires_page,
"requires_selection": self.requires_selection,
"min_selection": self.min_selection,
}
return wrapper
@ -103,7 +104,7 @@ def ribbon_action(
shortcut: Optional[str] = None,
requires_page: bool = False,
requires_selection: bool = False,
min_selection: int = 0
min_selection: int = 0,
) -> Callable:
"""
Convenience function for the RibbonAction decorator.
@ -133,7 +134,7 @@ def ribbon_action(
shortcut=shortcut,
requires_page=requires_page,
requires_selection=requires_selection,
min_selection=min_selection
min_selection=min_selection,
)
@ -175,14 +176,13 @@ class NumericalInput:
Returns:
The decorated function with metadata attached
"""
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
# Store metadata on wrapper function
wrapper._numerical_input = {
'fields': self.fields
}
wrapper._numerical_input = {"fields": self.fields}
return wrapper
@ -218,7 +218,7 @@ class UndoableOperation:
# Decorator handles undo/redo automatically
"""
def __init__(self, capture: str = 'page_elements', description: str = None):
def __init__(self, capture: str = "page_elements", description: str = None):
"""
Initialize the undoable operation decorator.
@ -241,10 +241,11 @@ class UndoableOperation:
Returns:
The decorated function
"""
@wraps(func)
def wrapper(self_instance, *args, **kwargs):
# Get description
description = self.description or func.__name__.replace('_', ' ').title()
description = self.description or func.__name__.replace("_", " ").title()
# Capture before state
before_state = self._capture_state(self_instance, self.capture)
@ -259,14 +260,15 @@ class UndoableOperation:
def restore_state(state):
self._restore_state(self_instance, self.capture, state)
# Update view after restoring
if hasattr(self_instance, 'update_view'):
if hasattr(self_instance, "update_view"):
self_instance.update_view()
# Create and execute command
from pyPhotoAlbum.commands import StateChangeCommand
cmd = StateChangeCommand(description, restore_state, before_state, after_state)
if hasattr(self_instance, 'project') and hasattr(self_instance.project, 'history'):
if hasattr(self_instance, "project") and hasattr(self_instance.project, "history"):
self_instance.project.history.execute(cmd)
print(f"Undoable operation '{description}' executed")
@ -276,9 +278,9 @@ class UndoableOperation:
def _capture_state(self, instance, capture_type: str):
"""Capture current state based on capture type"""
if capture_type == 'page_elements':
if capture_type == "page_elements":
# Capture elements from current page
current_page = instance.get_current_page() if hasattr(instance, 'get_current_page') else None
current_page = instance.get_current_page() if hasattr(instance, "get_current_page") else None
if current_page:
# Deep copy elements
return [copy.deepcopy(elem.serialize()) for elem in current_page.layout.elements]
@ -288,22 +290,23 @@ class UndoableOperation:
def _restore_state(self, instance, capture_type: str, state):
"""Restore state based on capture type"""
if capture_type == 'page_elements':
if capture_type == "page_elements":
# Restore elements to current page
current_page = instance.get_current_page() if hasattr(instance, 'get_current_page') else None
current_page = instance.get_current_page() if hasattr(instance, "get_current_page") else None
if current_page and state is not None:
# Clear existing elements
current_page.layout.elements.clear()
# Restore elements from serialized state
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
for elem_data in state:
elem_type = elem_data.get('type')
if elem_type == 'image':
elem_type = elem_data.get("type")
if elem_type == "image":
elem = ImageData()
elif elem_type == 'placeholder':
elif elem_type == "placeholder":
elem = PlaceholderData()
elif elem_type == 'textbox':
elif elem_type == "textbox":
elem = TextBoxData()
else:
continue
@ -312,7 +315,7 @@ class UndoableOperation:
current_page.layout.add_element(elem)
def undoable_operation(capture: str = 'page_elements', description: str = None) -> Callable:
def undoable_operation(capture: str = "page_elements", description: str = None) -> Callable:
"""
Convenience function for the UndoableOperation decorator.
@ -343,11 +346,7 @@ class DialogAction:
self.apply_page_setup(values)
"""
def __init__(
self,
dialog_class: type,
requires_pages: bool = True
):
def __init__(self, dialog_class: type, requires_pages: bool = True):
"""
Initialize the dialog action decorator.
@ -368,6 +367,7 @@ class DialogAction:
Returns:
The decorated function
"""
@wraps(func)
def wrapper(self_instance, *args, **kwargs):
# Check preconditions
@ -376,7 +376,7 @@ class DialogAction:
# Get initial page index if available
initial_page_index = 0
if hasattr(self_instance, '_get_most_visible_page_index'):
if hasattr(self_instance, "_get_most_visible_page_index"):
initial_page_index = self_instance._get_most_visible_page_index()
# Create and show dialog
@ -384,17 +384,15 @@ class DialogAction:
# Create dialog
dialog = self.dialog_class(
parent=self_instance,
project=self_instance.project,
initial_page_index=initial_page_index,
**kwargs
parent=self_instance, project=self_instance.project, initial_page_index=initial_page_index, **kwargs
)
# Show dialog and get result
from PyQt6.QtWidgets import QDialog
if dialog.exec() == QDialog.DialogCode.Accepted:
# Get values from dialog
if hasattr(dialog, 'get_values'):
if hasattr(dialog, "get_values"):
values = dialog.get_values()
# Call the decorated function with values
return func(self_instance, values, *args, **kwargs)
@ -406,10 +404,7 @@ class DialogAction:
return wrapper
def dialog_action(
dialog_class: type,
requires_pages: bool = True
) -> Callable:
def dialog_action(dialog_class: type, requires_pages: bool = True) -> Callable:
"""
Convenience function for the DialogAction decorator.

View File

@ -7,4 +7,4 @@ UI presentation logic separately from business logic.
from .page_setup_dialog import PageSetupDialog
__all__ = ['PageSetupDialog']
__all__ = ["PageSetupDialog"]

View File

@ -8,9 +8,16 @@ separating presentation from business logic.
import math
from typing import Optional, Dict, Any
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox,
QComboBox, QCheckBox
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QDoubleSpinBox,
QSpinBox,
QPushButton,
QGroupBox,
QComboBox,
QCheckBox,
)
from pyPhotoAlbum.project import Project
@ -23,12 +30,7 @@ class PageSetupDialog(QDialog):
including page size, DPI settings, and cover configuration.
"""
def __init__(
self,
parent,
project: Project,
initial_page_index: int = 0
):
def __init__(self, parent, project: Project, initial_page_index: int = 0):
"""
Initialize the page setup dialog.
@ -105,9 +107,7 @@ class PageSetupDialog(QDialog):
# Cover checkbox
self.cover_checkbox = QCheckBox("Designate as Cover")
self.cover_checkbox.setToolTip(
"Mark this page as the book cover with wrap-around front/spine/back"
)
self.cover_checkbox.setToolTip("Mark this page as the book cover with wrap-around front/spine/back")
layout.addWidget(self.cover_checkbox)
# Paper thickness
@ -136,9 +136,7 @@ class PageSetupDialog(QDialog):
# Calculated spine width display
self.spine_info_label = QLabel()
self.spine_info_label.setStyleSheet(
"font-size: 9pt; color: #0066cc; padding: 5px;"
)
self.spine_info_label.setStyleSheet("font-size: 9pt; color: #0066cc; padding: 5px;")
self.spine_info_label.setWordWrap(True)
layout.addWidget(self.spine_info_label)
@ -170,9 +168,7 @@ class PageSetupDialog(QDialog):
# Set as default checkbox
self.set_default_checkbox = QCheckBox("Set as default for new pages")
self.set_default_checkbox.setToolTip(
"Update project default page size for future pages"
)
self.set_default_checkbox.setToolTip("Update project default page size for future pages")
layout.addWidget(self.set_default_checkbox)
group.setLayout(layout)
@ -248,7 +244,7 @@ class PageSetupDialog(QDialog):
return
selected_page = self.project.pages[index]
is_first_page = (index == 0)
is_first_page = index == 0
# Show/hide cover settings based on page selection
self._cover_group.setVisible(is_first_page)
@ -265,7 +261,7 @@ class PageSetupDialog(QDialog):
elif selected_page.is_double_spread:
display_width = (
selected_page.layout.base_width
if hasattr(selected_page.layout, 'base_width')
if hasattr(selected_page.layout, "base_width")
else selected_page.layout.size[0] / 2
)
else:
@ -284,16 +280,12 @@ class PageSetupDialog(QDialog):
"""Update the spine information display."""
if self.cover_checkbox.isChecked():
# Calculate spine width with current settings
content_pages = sum(
p.get_page_count() for p in self.project.pages if not p.is_cover
)
content_pages = sum(p.get_page_count() for p in self.project.pages if not p.is_cover)
sheets = math.ceil(content_pages / 4)
spine_width = sheets * self.thickness_spinbox.value() * 2
page_width = self.project.page_size_mm[0]
total_width = (
(page_width * 2) + spine_width + (self.bleed_spinbox.value() * 2)
)
total_width = (page_width * 2) + spine_width + (self.bleed_spinbox.value() * 2)
self.spine_info_label.setText(
f"Cover Layout: Front ({page_width:.0f}mm) + "
@ -317,14 +309,14 @@ class PageSetupDialog(QDialog):
selected_page = self.project.pages[selected_index]
return {
'selected_index': selected_index,
'selected_page': selected_page,
'is_cover': self.cover_checkbox.isChecked(),
'paper_thickness_mm': self.thickness_spinbox.value(),
'cover_bleed_mm': self.bleed_spinbox.value(),
'width_mm': self.width_spinbox.value(),
'height_mm': self.height_spinbox.value(),
'working_dpi': self.working_dpi_spinbox.value(),
'export_dpi': self.export_dpi_spinbox.value(),
'set_as_default': self.set_default_checkbox.isChecked()
"selected_index": selected_index,
"selected_page": selected_page,
"is_cover": self.cover_checkbox.isChecked(),
"paper_thickness_mm": self.thickness_spinbox.value(),
"cover_bleed_mm": self.bleed_spinbox.value(),
"width_mm": self.width_spinbox.value(),
"height_mm": self.height_spinbox.value(),
"working_dpi": self.working_dpi_spinbox.value(),
"export_dpi": self.export_dpi_spinbox.value(),
"set_as_default": self.set_default_checkbox.isChecked(),
}

View File

@ -24,16 +24,13 @@ try:
GL_LINE_LOOP,
GL_LINES,
GL_TRIANGLE_FAN,
# Colors
glColor3f,
glColor4f,
# Line state
glLineWidth,
glLineStipple,
GL_LINE_STIPPLE,
# General state
glEnable,
glDisable,
@ -42,7 +39,6 @@ try:
GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA,
glBlendFunc,
# Textures
glGenTextures,
glBindTexture,
@ -56,7 +52,6 @@ try:
GL_TEXTURE_MAG_FILTER,
GL_LINEAR,
glTexCoord2f,
# Matrix operations
glPushMatrix,
glPopMatrix,
@ -64,24 +59,22 @@ try:
glTranslatef,
glLoadIdentity,
glRotatef,
# Clear operations
glClear,
glClearColor,
GL_COLOR_BUFFER_BIT,
GL_DEPTH_BUFFER_BIT,
# Viewport
glViewport,
glMatrixMode,
glOrtho,
GL_PROJECTION,
GL_MODELVIEW,
# Info/debug
glGetString,
GL_VERSION,
)
GL_AVAILABLE = True
except ImportError:

View File

@ -34,7 +34,7 @@ class GLWidget(
MouseInteractionMixin,
UndoableInteractionMixin,
KeyboardNavigationMixin,
QOpenGLWidget
QOpenGLWidget,
):
"""OpenGL widget for pyPhotoAlbum rendering and user interaction
@ -77,8 +77,8 @@ class GLWidget(
def _get_project_folder(self):
"""Override AssetPathMixin to access project via main window."""
main_window = self.window()
if hasattr(main_window, 'project') and main_window.project:
return getattr(main_window.project, 'folder_path', None)
if hasattr(main_window, "project") and main_window.project:
return getattr(main_window.project, "folder_path", None)
return None
def keyPressEvent(self, event):
@ -86,7 +86,7 @@ class GLWidget(
if event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace:
if self.selected_element:
main_window = self.window()
if hasattr(main_window, 'delete_selected_element'):
if hasattr(main_window, "delete_selected_element"):
main_window.delete_selected_element()
elif event.key() == Qt.Key.Key_Escape:
@ -99,7 +99,7 @@ class GLWidget(
if self.selected_element:
self.rotation_mode = not self.rotation_mode
main_window = self.window()
if hasattr(main_window, 'show_status'):
if hasattr(main_window, "show_status"):
mode_text = "Rotation Mode" if self.rotation_mode else "Move/Resize Mode"
main_window.show_status(f"Switched to {mode_text}", 2000)
print(f"Rotation mode: {self.rotation_mode}")

View File

@ -13,6 +13,7 @@ from PIL import Image
# Image Processing Utilities
# =============================================================================
def apply_pil_rotation(image: Image.Image, pil_rotation_90: int) -> Image.Image:
"""
Apply 90-degree rotation increments to a PIL image.
@ -48,8 +49,8 @@ def convert_to_rgba(image: Image.Image) -> Image.Image:
Returns:
PIL Image in RGBA mode
"""
if image.mode != 'RGBA':
return image.convert('RGBA')
if image.mode != "RGBA":
return image.convert("RGBA")
return image
@ -58,7 +59,7 @@ def calculate_center_crop_coords(
img_height: int,
target_width: float,
target_height: float,
crop_info: Tuple[float, float, float, float] = (0, 0, 1, 1)
crop_info: Tuple[float, float, float, float] = (0, 0, 1, 1),
) -> Tuple[float, float, float, float]:
"""
Calculate texture/crop coordinates for center-crop fitting an image to a target aspect ratio.
@ -113,10 +114,7 @@ def calculate_center_crop_coords(
return (tx_min, ty_min, tx_max, ty_max)
def crop_image_to_coords(
image: Image.Image,
coords: Tuple[float, float, float, float]
) -> Image.Image:
def crop_image_to_coords(image: Image.Image, coords: Tuple[float, float, float, float]) -> Image.Image:
"""
Crop an image using normalized texture coordinates.
@ -139,9 +137,7 @@ def crop_image_to_coords(
def resize_to_fit(
image: Image.Image,
max_size: int,
resample: Image.Resampling = Image.Resampling.LANCZOS
image: Image.Image, max_size: int, resample: Image.Resampling = Image.Resampling.LANCZOS
) -> Image.Image:
"""
Resize image to fit within max_size while preserving aspect ratio.

View File

@ -29,7 +29,8 @@ class LoadingWidget(QWidget):
self.setFixedSize(280, 80)
# Styling
self.setStyleSheet("""
self.setStyleSheet(
"""
QWidget {
background-color: rgba(50, 50, 50, 230);
border-radius: 8px;
@ -54,7 +55,8 @@ class LoadingWidget(QWidget):
stop:1 rgba(100, 160, 210, 220));
border-radius: 3px;
}
""")
"""
)
# Layout
layout = QVBoxLayout()

View File

@ -9,8 +9,14 @@ import sys
from datetime import datetime
from pathlib import Path
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QVBoxLayout, QWidget,
QStatusBar, QScrollBar, QHBoxLayout, QMessageBox
QApplication,
QMainWindow,
QVBoxLayout,
QWidget,
QStatusBar,
QScrollBar,
QHBoxLayout,
QMessageBox,
)
from PyQt6.QtCore import Qt, QSize, QTimer
from PyQt6.QtGui import QIcon
@ -85,7 +91,7 @@ class MainWindow(
self._autosave_timer.start(5 * 60 * 1000) # 5 minutes in milliseconds
# Add a sample page for demonstration
#self._add_sample_page()
# self._add_sample_page()
def _init_state(self):
"""Initialize shared application state"""
@ -94,6 +100,7 @@ class MainWindow(
# Set asset resolution context
from pyPhotoAlbum.models import set_asset_resolution_context
set_asset_resolution_context(self._project.folder_path)
# Initialize template manager
@ -206,8 +213,8 @@ class MainWindow(
viewport_width = self._gl_widget.width()
viewport_height = self._gl_widget.height()
content_height = bounds['height']
content_width = bounds['width']
content_height = bounds["height"]
content_width = bounds["width"]
# Vertical scrollbar
# Scrollbar value 0 = top of content
@ -257,7 +264,7 @@ class MainWindow(
print(f"Registered shortcut: {shortcut_str} -> {method_name}")
# Register additional Ctrl+Shift+Z shortcut for redo
if hasattr(self, '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")
@ -265,7 +272,7 @@ class MainWindow(
def resizeEvent(self, event):
"""Handle window resize to reposition loading widget"""
super().resizeEvent(event)
if hasattr(self, '_loading_widget'):
if hasattr(self, "_loading_widget"):
self._loading_widget.resizeParent()
def _add_sample_page(self):
@ -315,8 +322,8 @@ class MainWindow(
return
checkpoint_path, metadata = checkpoint_info
project_name = metadata.get('project_name', 'Unknown')
timestamp_str = metadata.get('timestamp', 'Unknown time')
project_name = metadata.get("project_name", "Unknown")
timestamp_str = metadata.get("timestamp", "Unknown time")
# Parse timestamp for better display
try:
@ -334,7 +341,7 @@ class MainWindow(
f"Time: {time_display}\n\n"
f"Would you like to recover this checkpoint?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes
QMessageBox.StandardButton.Yes,
)
if reply == QMessageBox.StandardButton.Yes:
@ -343,7 +350,7 @@ class MainWindow(
if success:
# Replace current project with recovered one
if hasattr(self, '_project') and self._project:
if hasattr(self, "_project") and self._project:
self._project.cleanup()
self._project = result
@ -365,8 +372,10 @@ class MainWindow(
self,
"Unsaved Changes",
"You have unsaved changes. Would you like to save before exiting?",
QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Save
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Save,
)
if reply == QMessageBox.StandardButton.Save:
@ -390,7 +399,7 @@ class MainWindow(
self.project.cleanup()
# Stop autosave timer
if hasattr(self, '_autosave_timer'):
if hasattr(self, "_autosave_timer"):
self._autosave_timer.stop()
# Cleanup old checkpoints

View File

@ -3,9 +3,21 @@ Merge dialog for resolving project conflicts visually
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
QListWidget, QListWidgetItem, QSplitter, QWidget, QScrollArea,
QRadioButton, QButtonGroup, QTextEdit, QComboBox, QGroupBox
QDialog,
QVBoxLayout,
QHBoxLayout,
QPushButton,
QLabel,
QListWidget,
QListWidgetItem,
QSplitter,
QWidget,
QScrollArea,
QRadioButton,
QButtonGroup,
QTextEdit,
QComboBox,
QGroupBox,
)
from PyQt6.QtCore import Qt, QSize, pyqtSignal
from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QPen
@ -22,10 +34,7 @@ class PagePreviewWidget(QWidget):
super().__init__(parent)
self.page_data = page_data
self.setMinimumSize(200, 280)
self.setSizePolicy(
self.sizePolicy().Policy.Expanding,
self.sizePolicy().Policy.Expanding
)
self.setSizePolicy(self.sizePolicy().Policy.Expanding, self.sizePolicy().Policy.Expanding)
def paintEvent(self, event):
"""Render the page preview"""
@ -356,8 +365,4 @@ class MergeDialog(QDialog):
Returns:
Merged project data dictionary
"""
return self.merge_manager.apply_resolutions(
self.our_project_data,
self.their_project_data,
self.resolutions
)
return self.merge_manager.apply_resolutions(self.our_project_data, self.their_project_data, self.resolutions)

View File

@ -16,6 +16,7 @@ from datetime import datetime, timezone
class ConflictType(Enum):
"""Types of merge conflicts"""
# Page-level conflicts
PAGE_MODIFIED_BOTH = "page_modified_both" # Page modified in both versions
PAGE_DELETED_ONE = "page_deleted_one" # Page deleted in one version, modified in other
@ -31,6 +32,7 @@ class ConflictType(Enum):
class MergeStrategy(Enum):
"""Automatic merge resolution strategies"""
LATEST_WINS = "latest_wins" # Most recent last_modified wins
OURS = "ours" # Always use our version
THEIRS = "theirs" # Always use their version
@ -40,6 +42,7 @@ class MergeStrategy(Enum):
@dataclass
class ConflictInfo:
"""Information about a single merge conflict"""
conflict_type: ConflictType
page_uuid: Optional[str] # UUID of the page (if page-level conflict)
element_uuid: Optional[str] # UUID of the element (if element-level conflict)
@ -79,9 +82,7 @@ class MergeManager:
return project_a_id == project_b_id
def detect_conflicts(
self,
our_project_data: Dict[str, Any],
their_project_data: Dict[str, Any]
self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any]
) -> List[ConflictInfo]:
"""
Detect conflicts between two versions of the same project.
@ -103,16 +104,18 @@ class MergeManager:
return self.conflicts
def _detect_project_settings_conflicts(
self,
our_data: Dict[str, Any],
their_data: Dict[str, Any]
):
def _detect_project_settings_conflicts(self, our_data: Dict[str, Any], their_data: Dict[str, Any]):
"""Detect conflicts in project-level settings."""
# Settings that can conflict
settings_keys = [
"name", "page_size_mm", "working_dpi", "export_dpi",
"has_cover", "paper_thickness_mm", "cover_bleed_mm", "binding_type"
"name",
"page_size_mm",
"working_dpi",
"export_dpi",
"has_cover",
"paper_thickness_mm",
"cover_bleed_mm",
"binding_type",
]
our_modified = our_data.get("last_modified")
@ -124,20 +127,18 @@ class MergeManager:
# If values differ, it's a conflict
if our_value != their_value:
self.conflicts.append(ConflictInfo(
conflict_type=ConflictType.SETTINGS_MODIFIED_BOTH,
page_uuid=None,
element_uuid=None,
our_version={key: our_value, "last_modified": our_modified},
their_version={key: their_value, "last_modified": their_modified},
description=f"Project setting '{key}' modified in both versions"
))
self.conflicts.append(
ConflictInfo(
conflict_type=ConflictType.SETTINGS_MODIFIED_BOTH,
page_uuid=None,
element_uuid=None,
our_version={key: our_value, "last_modified": our_modified},
their_version={key: their_value, "last_modified": their_modified},
description=f"Project setting '{key}' modified in both versions",
)
)
def _detect_page_conflicts(
self,
our_data: Dict[str, Any],
their_data: Dict[str, Any]
):
def _detect_page_conflicts(self, our_data: Dict[str, Any], their_data: Dict[str, Any]):
"""Detect conflicts at page level."""
our_pages = {page["uuid"]: page for page in our_data.get("pages", [])}
their_pages = {page["uuid"]: page for page in their_data.get("pages", [])}
@ -164,12 +165,7 @@ class MergeManager:
# Unless we deleted it
pass
def _detect_page_modification_conflicts(
self,
page_uuid: str,
our_page: Dict[str, Any],
their_page: Dict[str, Any]
):
def _detect_page_modification_conflicts(self, page_uuid: str, our_page: Dict[str, Any], their_page: Dict[str, Any]):
"""Detect conflicts in a specific page."""
our_modified = our_page.get("last_modified")
their_modified = their_page.get("last_modified")
@ -180,14 +176,16 @@ class MergeManager:
# Check if one deleted, one modified
if our_page.get("deleted") != their_page.get("deleted"):
self.conflicts.append(ConflictInfo(
conflict_type=ConflictType.PAGE_DELETED_ONE,
page_uuid=page_uuid,
element_uuid=None,
our_version=our_page,
their_version=their_page,
description=f"Page deleted in one version but modified in the other"
))
self.conflicts.append(
ConflictInfo(
conflict_type=ConflictType.PAGE_DELETED_ONE,
page_uuid=page_uuid,
element_uuid=None,
our_version=our_page,
their_version=their_page,
description=f"Page deleted in one version but modified in the other",
)
)
return
# Check page-level properties
@ -201,24 +199,21 @@ class MergeManager:
# Only flag as conflict if properties differ AND timestamps are identical
# (See element conflict detection for detailed explanation of this strategy)
if page_modified and our_modified == their_modified:
self.conflicts.append(ConflictInfo(
conflict_type=ConflictType.PAGE_MODIFIED_BOTH,
page_uuid=page_uuid,
element_uuid=None,
our_version=our_page,
their_version=their_page,
description=f"Page properties modified with same timestamp (possible conflict)"
))
self.conflicts.append(
ConflictInfo(
conflict_type=ConflictType.PAGE_MODIFIED_BOTH,
page_uuid=page_uuid,
element_uuid=None,
our_version=our_page,
their_version=their_page,
description=f"Page properties modified with same timestamp (possible conflict)",
)
)
# Check element-level conflicts
self._detect_element_conflicts(page_uuid, our_page, their_page)
def _detect_element_conflicts(
self,
page_uuid: str,
our_page: Dict[str, Any],
their_page: Dict[str, Any]
):
def _detect_element_conflicts(self, page_uuid: str, our_page: Dict[str, Any], their_page: Dict[str, Any]):
"""Detect conflicts in elements within a page."""
our_layout = our_page.get("layout", {})
their_layout = their_page.get("layout", {})
@ -238,16 +233,10 @@ class MergeManager:
continue
# Element exists in both - check for modifications
self._detect_element_modification_conflicts(
page_uuid, elem_uuid, our_elem, their_elem
)
self._detect_element_modification_conflicts(page_uuid, elem_uuid, our_elem, their_elem)
def _detect_element_modification_conflicts(
self,
page_uuid: str,
elem_uuid: str,
our_elem: Dict[str, Any],
their_elem: Dict[str, Any]
self, page_uuid: str, elem_uuid: str, our_elem: Dict[str, Any], their_elem: Dict[str, Any]
):
"""Detect conflicts in a specific element."""
our_modified = our_elem.get("last_modified")
@ -259,14 +248,16 @@ class MergeManager:
# Check if one deleted, one modified
if our_elem.get("deleted") != their_elem.get("deleted"):
self.conflicts.append(ConflictInfo(
conflict_type=ConflictType.ELEMENT_DELETED_ONE,
page_uuid=page_uuid,
element_uuid=elem_uuid,
our_version=our_elem,
their_version=their_elem,
description=f"Element deleted in one version but modified in the other"
))
self.conflicts.append(
ConflictInfo(
conflict_type=ConflictType.ELEMENT_DELETED_ONE,
page_uuid=page_uuid,
element_uuid=elem_uuid,
our_version=our_elem,
their_version=their_elem,
description=f"Element deleted in one version but modified in the other",
)
)
return
# Check element properties
@ -298,22 +289,21 @@ class MergeManager:
# Properties differ but timestamps match - this is unusual and might indicate
# that both versions modified it at exactly the same time, or there's data corruption.
# Flag as conflict to be safe.
self.conflicts.append(ConflictInfo(
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
page_uuid=page_uuid,
element_uuid=elem_uuid,
our_version=our_elem,
their_version=their_elem,
description=f"Element modified with same timestamp (possible conflict)"
))
self.conflicts.append(
ConflictInfo(
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
page_uuid=page_uuid,
element_uuid=elem_uuid,
our_version=our_elem,
their_version=their_elem,
description=f"Element modified with same timestamp (possible conflict)",
)
)
# Note: If timestamps differ, we assume one version modified it and the other didn't.
# The _merge_non_conflicting_changes method will automatically use the newer version.
def auto_resolve_conflicts(
self,
strategy: MergeStrategy = MergeStrategy.LATEST_WINS
) -> Dict[str, Any]:
def auto_resolve_conflicts(self, strategy: MergeStrategy = MergeStrategy.LATEST_WINS) -> Dict[str, Any]:
"""
Automatically resolve conflicts based on a strategy.
@ -353,10 +343,7 @@ class MergeManager:
return None
def apply_resolutions(
self,
our_project_data: Dict[str, Any],
their_project_data: Dict[str, Any],
resolutions: Dict[int, str]
self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any], resolutions: Dict[int, str]
) -> Dict[str, Any]:
"""
Apply conflict resolutions to create merged project.
@ -415,20 +402,12 @@ class MergeManager:
break
break
def _merge_non_conflicting_changes(
self,
merged_data: Dict[str, Any],
their_data: Dict[str, Any]
):
def _merge_non_conflicting_changes(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
"""Add non-conflicting pages and elements from their version."""
self._add_missing_pages(merged_data, their_data)
self._merge_page_elements(merged_data, their_data)
def _add_missing_pages(
self,
merged_data: Dict[str, Any],
their_data: Dict[str, Any]
):
def _add_missing_pages(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
"""Add pages that exist only in their version."""
our_page_uuids = {page["uuid"] for page in merged_data.get("pages", [])}
@ -436,11 +415,7 @@ class MergeManager:
if their_page["uuid"] not in our_page_uuids:
merged_data["pages"].append(their_page)
def _merge_page_elements(
self,
merged_data: Dict[str, Any],
their_data: Dict[str, Any]
):
def _merge_page_elements(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
"""For pages that exist in both versions, merge their elements."""
their_pages = {page["uuid"]: page for page in their_data.get("pages", [])}
@ -449,25 +424,15 @@ class MergeManager:
if not their_page:
continue
our_elements = {
elem["uuid"]: elem
for elem in our_page.get("layout", {}).get("elements", [])
}
our_elements = {elem["uuid"]: elem for elem in our_page.get("layout", {}).get("elements", [])}
for their_elem in their_page.get("layout", {}).get("elements", []):
self._merge_element(
our_page=our_page,
page_uuid=our_page["uuid"],
their_elem=their_elem,
our_elements=our_elements
our_page=our_page, page_uuid=our_page["uuid"], their_elem=their_elem, our_elements=our_elements
)
def _merge_element(
self,
our_page: Dict[str, Any],
page_uuid: str,
their_elem: Dict[str, Any],
our_elements: Dict[str, Any]
self, our_page: Dict[str, Any], page_uuid: str, their_elem: Dict[str, Any], our_elements: Dict[str, Any]
):
"""Merge a single element from their version into our page."""
elem_uuid = their_elem["uuid"]
@ -486,17 +451,10 @@ class MergeManager:
def _is_element_in_conflict(self, elem_uuid: str, page_uuid: str) -> bool:
"""Check if element was part of a conflict that was already resolved."""
return any(
c.element_uuid == elem_uuid and c.page_uuid == page_uuid
for c in self.conflicts
)
return any(c.element_uuid == elem_uuid and c.page_uuid == page_uuid for c in self.conflicts)
def _merge_by_timestamp(
self,
our_page: Dict[str, Any],
elem_uuid: str,
their_elem: Dict[str, Any],
our_elem: Dict[str, Any]
self, our_page: Dict[str, Any], elem_uuid: str, their_elem: Dict[str, Any], our_elem: Dict[str, Any]
):
"""Use the more recently modified version of an element."""
our_modified = our_elem.get("last_modified")
@ -513,10 +471,7 @@ class MergeManager:
break
def concatenate_projects(
project_a_data: Dict[str, Any],
project_b_data: Dict[str, Any]
) -> Dict[str, Any]:
def concatenate_projects(project_a_data: Dict[str, Any], project_b_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Concatenate two projects with different project_ids.
@ -542,6 +497,8 @@ def concatenate_projects(
# Update last_modified to now
merged_data["last_modified"] = datetime.now(timezone.utc).isoformat()
print(f"Concatenated projects: {len(project_a_data.get('pages', []))} + {len(project_b_data.get('pages', []))} = {len(merged_data['pages'])} pages")
print(
f"Concatenated projects: {len(project_a_data.get('pages', []))} + {len(project_b_data.get('pages', []))} = {len(merged_data['pages'])} pages"
)
return merged_data

View File

@ -5,4 +5,4 @@ Mixin modules for pyPhotoAlbum
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
__all__ = ['ApplicationStateMixin', 'DialogMixin']
__all__ = ["ApplicationStateMixin", "DialogMixin"]

View File

@ -14,7 +14,7 @@ class AssetDropMixin:
or updating ImageData elements.
"""
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp']
IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"]
def dragEnterEvent(self, event):
"""Handle drag enter events"""
@ -66,7 +66,7 @@ class AssetDropMixin:
def _handle_drop_on_element(self, image_path, target_element):
"""Handle dropping an image onto an existing element"""
main_window = self.window()
if not (hasattr(main_window, 'project') and main_window.project):
if not (hasattr(main_window, "project") and main_window.project):
return
try:
@ -89,7 +89,7 @@ class AssetDropMixin:
y=placeholder.position[1],
width=placeholder.size[0],
height=placeholder.size[1],
z_index=placeholder.z_index
z_index=placeholder.z_index,
)
if not main_window.project.pages:
@ -104,7 +104,7 @@ class AssetDropMixin:
def _handle_drop_on_empty_space(self, image_path, x, y):
"""Handle dropping an image onto empty space"""
main_window = self.window()
if not (hasattr(main_window, 'project') and main_window.project and main_window.project.pages):
if not (hasattr(main_window, "project") and main_window.project and main_window.project.pages):
return
target_page, page_index, page_renderer = self._get_page_at(x, y)
@ -120,8 +120,7 @@ class AssetDropMixin:
img_width, img_height = self._calculate_image_dimensions(full_asset_path)
self._add_new_image_to_page(
asset_path, target_page, page_index, page_renderer,
x, y, img_width, img_height, main_window
asset_path, target_page, page_index, page_renderer, x, y, img_width, img_height, main_window
)
except Exception as e:
print(f"Error importing dropped image: {e}")
@ -138,27 +137,18 @@ class AssetDropMixin:
# Fallback dimensions if image cannot be read
return 200, 150
def _add_new_image_to_page(self, asset_path, target_page, page_index,
page_renderer, x, y, img_width, img_height, main_window):
def _add_new_image_to_page(
self, asset_path, target_page, page_index, page_renderer, x, y, img_width, img_height, main_window
):
"""Add a new image element to the target page (asset already imported)"""
if page_index >= 0:
self.current_page_index = page_index
page_local_x, page_local_y = page_renderer.screen_to_page(x, y)
new_image = ImageData(
image_path=asset_path,
x=page_local_x,
y=page_local_y,
width=img_width,
height=img_height
)
new_image = ImageData(image_path=asset_path, x=page_local_x, y=page_local_y, width=img_width, height=img_height)
cmd = AddElementCommand(
target_page.layout,
new_image,
asset_manager=main_window.project.asset_manager
)
cmd = AddElementCommand(target_page.layout, new_image, asset_manager=main_window.project.asset_manager)
main_window.project.history.execute(cmd)
print(f"Added new image to page {page_index + 1} at ({page_local_x:.1f}, {page_local_y:.1f}): {asset_path}")

View File

@ -63,6 +63,6 @@ class AssetPathMixin:
Override this method if the project is accessed differently.
Default implementation uses self.project.folder_path.
"""
if hasattr(self, 'project') and self.project:
return getattr(self.project, 'folder_path', None)
if hasattr(self, "project") and self.project:
return getattr(self.project, "folder_path", None)
return None

View File

@ -48,13 +48,13 @@ class AsyncLoadingMixin:
"""Cleanup async loading components."""
logger.info("Cleaning up async loading system...")
if hasattr(self, 'async_image_loader'):
if hasattr(self, "async_image_loader"):
self.async_image_loader.stop()
if hasattr(self, 'async_pdf_generator'):
if hasattr(self, "async_pdf_generator"):
self.async_pdf_generator.stop()
if hasattr(self, 'image_cache'):
if hasattr(self, "image_cache"):
self.image_cache.clear()
logger.info("Async loading system cleaned up")
@ -70,7 +70,7 @@ class AsyncLoadingMixin:
"""
logger.debug(f"Image loaded callback: {path}")
if user_data and hasattr(user_data, '_on_async_image_loaded'):
if user_data and hasattr(user_data, "_on_async_image_loaded"):
user_data._on_async_image_loaded(image)
# Trigger re-render to show newly loaded image
@ -87,7 +87,7 @@ class AsyncLoadingMixin:
"""
logger.warning(f"Image load failed: {path} - {error_msg}")
if user_data and hasattr(user_data, '_on_async_image_load_failed'):
if user_data and hasattr(user_data, "_on_async_image_load_failed"):
user_data._on_async_image_load_failed(error_msg)
def _on_pdf_progress(self, current: int, total: int, message: str):
@ -102,7 +102,7 @@ class AsyncLoadingMixin:
logger.debug(f"PDF progress: {current}/{total} - {message}")
# Update progress dialog if it exists
if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog:
if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog:
self._pdf_progress_dialog.setValue(current)
self._pdf_progress_dialog.setLabelText(message)
@ -117,19 +117,16 @@ class AsyncLoadingMixin:
logger.info(f"PDF export complete: success={success}, warnings={len(warnings)}")
# Close progress dialog
if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog:
if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog:
self._pdf_progress_dialog.close()
self._pdf_progress_dialog = None
# Show completion message
main_window = self.window()
if hasattr(main_window, 'show_status'):
if hasattr(main_window, "show_status"):
if success:
if warnings:
main_window.show_status(
f"PDF exported successfully with {len(warnings)} warnings",
5000
)
main_window.show_status(f"PDF exported successfully with {len(warnings)} warnings", 5000)
else:
main_window.show_status("PDF exported successfully", 3000)
else:
@ -145,13 +142,13 @@ class AsyncLoadingMixin:
logger.error(f"PDF export failed: {error_msg}")
# Close progress dialog
if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog:
if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog:
self._pdf_progress_dialog.close()
self._pdf_progress_dialog = None
# Show error message
main_window = self.window()
if hasattr(main_window, 'show_status'):
if hasattr(main_window, "show_status"):
main_window.show_status(f"PDF export failed: {error_msg}", 5000)
def request_image_load(self, image_data, priority: LoadPriority = LoadPriority.NORMAL):
@ -162,7 +159,7 @@ class AsyncLoadingMixin:
image_data: ImageData element to load
priority: Load priority level
"""
if not hasattr(self, 'async_image_loader'):
if not hasattr(self, "async_image_loader"):
logger.warning("Async image loader not initialized")
return
@ -188,7 +185,7 @@ class AsyncLoadingMixin:
Path(image_full_path),
priority=priority,
target_size=target_size,
user_data=image_data # Pass element for callback
user_data=image_data, # Pass element for callback
)
def export_pdf_async(self, project, output_path: str, export_dpi: int = 300):
@ -200,7 +197,7 @@ class AsyncLoadingMixin:
output_path: Output PDF file path
export_dpi: Export DPI (default 300)
"""
if not hasattr(self, 'async_pdf_generator'):
if not hasattr(self, "async_pdf_generator"):
logger.warning("Async PDF generator not initialized")
return False
@ -208,18 +205,9 @@ class AsyncLoadingMixin:
from PyQt6.QtWidgets import QProgressDialog
from PyQt6.QtCore import Qt
total_pages = sum(
1 if page.is_cover else (2 if page.is_double_spread else 1)
for page in project.pages
)
total_pages = sum(1 if page.is_cover else (2 if page.is_double_spread else 1) for page in project.pages)
self._pdf_progress_dialog = QProgressDialog(
"Exporting to PDF...",
"Cancel",
0,
total_pages,
self
)
self._pdf_progress_dialog = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, self)
self._pdf_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
self._pdf_progress_dialog.setWindowTitle("PDF Export")
self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel)
@ -232,17 +220,17 @@ class AsyncLoadingMixin:
"""Handle PDF export cancellation."""
logger.info("User requested PDF export cancellation")
if hasattr(self, 'async_pdf_generator'):
if hasattr(self, "async_pdf_generator"):
self.async_pdf_generator.cancel_export()
def get_async_stats(self) -> dict:
"""Get async loading system statistics."""
stats = {}
if hasattr(self, 'async_image_loader'):
stats['image_loader'] = self.async_image_loader.get_stats()
if hasattr(self, "async_image_loader"):
stats["image_loader"] = self.async_image_loader.get_stats()
if hasattr(self, 'async_pdf_generator'):
stats['pdf_generator'] = self.async_pdf_generator.get_stats()
if hasattr(self, "async_pdf_generator"):
stats["pdf_generator"] = self.async_pdf_generator.get_stats()
return stats

View File

@ -23,7 +23,7 @@ class ApplicationStateMixin:
@property
def project(self):
"""Access to current project"""
if not hasattr(self, '_project'):
if not hasattr(self, "_project"):
raise AttributeError("MainWindow must set _project attribute")
return self._project
@ -35,21 +35,21 @@ class ApplicationStateMixin:
@property
def gl_widget(self):
"""Access to GL rendering widget"""
if not hasattr(self, '_gl_widget'):
if not hasattr(self, "_gl_widget"):
raise AttributeError("MainWindow must set _gl_widget attribute")
return self._gl_widget
@property
def status_bar(self) -> QStatusBar:
"""Access to status bar"""
if not hasattr(self, '_status_bar'):
if not hasattr(self, "_status_bar"):
raise AttributeError("MainWindow must set _status_bar attribute")
return self._status_bar
@property
def template_manager(self):
"""Access to template manager"""
if not hasattr(self, '_template_manager'):
if not hasattr(self, "_template_manager"):
raise AttributeError("MainWindow must set _template_manager attribute")
return self._template_manager
@ -62,7 +62,7 @@ class ApplicationStateMixin:
Returns:
int: Index of the most visible page
"""
if not hasattr(self.gl_widget, '_page_renderers') or not self.gl_widget._page_renderers:
if not hasattr(self.gl_widget, "_page_renderers") or not self.gl_widget._page_renderers:
return self.gl_widget.current_page_index
# Get viewport dimensions
@ -70,7 +70,7 @@ class ApplicationStateMixin:
viewport_center_y = viewport_height / 2
# Find which page's center is closest to viewport center
min_distance = float('inf')
min_distance = float("inf")
best_page_index = self.gl_widget.current_page_index
for renderer, page in self.gl_widget._page_renderers:
@ -197,10 +197,7 @@ class ApplicationStateMixin:
if min_count == 1:
self.show_info("No Selection", "Please select an element.")
else:
self.show_info(
"Selection Required",
f"Please select at least {min_count} elements."
)
self.show_info("Selection Required", f"Please select at least {min_count} elements.")
return False
return True
@ -211,5 +208,5 @@ class ApplicationStateMixin:
self.gl_widget.update()
# Update scrollbars to reflect new content
if hasattr(self, 'update_scrollbars'):
if hasattr(self, "update_scrollbars"):
self.update_scrollbars()

View File

@ -16,12 +16,7 @@ class DialogMixin:
making it easier to create, test, and maintain complex dialogs.
"""
def create_dialog(
self,
dialog_class: type,
title: Optional[str] = None,
**kwargs
) -> Optional[Any]:
def create_dialog(self, dialog_class: type, title: Optional[str] = None, **kwargs) -> Optional[Any]:
"""
Create and show a dialog, handling the result.
@ -43,18 +38,13 @@ class DialogMixin:
# Show dialog and handle result
if dialog.exec() == QDialog.DialogCode.Accepted:
# Check if dialog has a get_values method
if hasattr(dialog, 'get_values'):
if hasattr(dialog, "get_values"):
return dialog.get_values()
return True
return None
def show_dialog(
self,
dialog_class: type,
on_accept: Optional[Callable] = None,
**kwargs
) -> bool:
def show_dialog(self, dialog_class: type, on_accept: Optional[Callable] = None, **kwargs) -> bool:
"""
Show a dialog and execute callback on acceptance.

View File

@ -27,11 +27,7 @@ class ElementManipulationMixin:
self.rotation_snap_angle: int = 15 # Default snap angle in degrees
# Snap state tracking
self.snap_state = {
'is_snapped': False,
'last_position': None,
'last_size': None
}
self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None}
def _resize_element(self, dx: float, dy: float):
"""
@ -49,7 +45,7 @@ class ElementManipulationMixin:
# Get the snapping system from the element's parent page
main_window = self.window()
if not hasattr(self.selected_element, '_parent_page'):
if not hasattr(self.selected_element, "_parent_page"):
self._resize_element_no_snap(dx, dy)
return
@ -62,6 +58,7 @@ class ElementManipulationMixin:
# Apply snapping to resize
from pyPhotoAlbum.snapping import SnapResizeParams
params = SnapResizeParams(
position=self.resize_start_pos,
size=self.resize_start_size,
@ -70,7 +67,7 @@ class ElementManipulationMixin:
resize_handle=self.resize_handle,
page_size=page_size,
dpi=dpi,
project=main_window.project
project=main_window.project,
)
new_pos, new_size = snap_sys.snap_resize(params)
@ -100,16 +97,16 @@ class ElementManipulationMixin:
start_x, start_y = self.resize_start_pos
start_w, start_h = self.resize_start_size
if self.resize_handle == 'nw':
if self.resize_handle == "nw":
self.selected_element.position = (start_x + dx, start_y + dy)
self.selected_element.size = (start_w - dx, start_h - dy)
elif self.resize_handle == 'ne':
elif self.resize_handle == "ne":
self.selected_element.position = (start_x, start_y + dy)
self.selected_element.size = (start_w + dx, start_h - dy)
elif self.resize_handle == 'sw':
elif self.resize_handle == "sw":
self.selected_element.position = (start_x + dx, start_y)
self.selected_element.size = (start_w - dx, start_h + dy)
elif self.resize_handle == 'se':
elif self.resize_handle == "se":
self.selected_element.size = (start_w + dx, start_h + dy)
# Ensure minimum size
@ -121,7 +118,9 @@ class ElementManipulationMixin:
w, _ = self.selected_element.size
self.selected_element.size = (w, min_size)
def _transfer_element_to_page(self, element, source_page, target_page, mouse_x: float, mouse_y: float, target_renderer):
def _transfer_element_to_page(
self, element, source_page, target_page, mouse_x: float, mouse_y: float, target_renderer
):
"""
Transfer an element from one page to another during drag operation.

View File

@ -54,7 +54,7 @@ class ElementSelectionMixin:
Returns:
BaseLayoutElement or None: The topmost element at the position, or None
"""
if not hasattr(self, '_page_renderers') or not self._page_renderers:
if not hasattr(self, "_page_renderers") or not self._page_renderers:
return None
# Check each page from top to bottom (reverse z-order)
@ -96,11 +96,11 @@ class ElementSelectionMixin:
return None
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return None
# Get the PageRenderer for this element (stored when element was selected)
if not hasattr(self.selected_element, '_page_renderer'):
if not hasattr(self.selected_element, "_page_renderer"):
return None
renderer = self.selected_element._page_renderer
@ -117,10 +117,10 @@ class ElementSelectionMixin:
# Check handles (no rotation transformation needed - images are already rotated)
handles = {
'nw': (ex - handle_size/2, ey - handle_size/2),
'ne': (ex + ew - handle_size/2, ey - handle_size/2),
'sw': (ex - handle_size/2, ey + eh - handle_size/2),
'se': (ex + ew - handle_size/2, ey + eh - handle_size/2),
"nw": (ex - handle_size / 2, ey - handle_size / 2),
"ne": (ex + ew - handle_size / 2, ey - handle_size / 2),
"sw": (ex - handle_size / 2, ey + eh - handle_size / 2),
"se": (ex + ew - handle_size / 2, ey + eh - handle_size / 2),
}
for name, (hx, hy) in handles.items():

View File

@ -59,7 +59,7 @@ class MoveCommandBuilder(CommandBuilder):
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""Check if position changed significantly."""
old_pos = start_state.get('position')
old_pos = start_state.get("position")
if old_pos is None:
return False
@ -68,7 +68,7 @@ class MoveCommandBuilder(CommandBuilder):
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a MoveElementCommand."""
old_pos = start_state.get('position')
old_pos = start_state.get("position")
if old_pos is None:
return None
@ -79,6 +79,7 @@ class MoveCommandBuilder(CommandBuilder):
return None
from pyPhotoAlbum.commands import MoveElementCommand
command = MoveElementCommand(element, old_pos, new_pos)
self.log_command("Move", f"{old_pos}{new_pos}")
@ -90,8 +91,8 @@ class ResizeCommandBuilder(CommandBuilder):
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""Check if position or size changed significantly."""
old_pos = start_state.get('position')
old_size = start_state.get('size')
old_pos = start_state.get("position")
old_size = start_state.get("size")
if old_pos is None or old_size is None:
return False
@ -106,8 +107,8 @@ class ResizeCommandBuilder(CommandBuilder):
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a ResizeElementCommand."""
old_pos = start_state.get('position')
old_size = start_state.get('size')
old_pos = start_state.get("position")
old_size = start_state.get("size")
if old_pos is None or old_size is None:
return None
@ -119,6 +120,7 @@ class ResizeCommandBuilder(CommandBuilder):
return None
from pyPhotoAlbum.commands import ResizeElementCommand
command = ResizeElementCommand(element, old_pos, old_size, new_pos, new_size)
self.log_command("Resize", f"{old_size}{new_size}")
@ -130,7 +132,7 @@ class RotateCommandBuilder(CommandBuilder):
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""Check if rotation changed significantly."""
old_rotation = start_state.get('rotation')
old_rotation = start_state.get("rotation")
if old_rotation is None:
return False
@ -139,7 +141,7 @@ class RotateCommandBuilder(CommandBuilder):
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a RotateElementCommand."""
old_rotation = start_state.get('rotation')
old_rotation = start_state.get("rotation")
if old_rotation is None:
return None
@ -150,6 +152,7 @@ class RotateCommandBuilder(CommandBuilder):
return None
from pyPhotoAlbum.commands import RotateElementCommand
command = RotateElementCommand(element, old_rotation, new_rotation)
self.log_command("Rotation", f"{old_rotation:.1f}° → {new_rotation:.1f}°")
@ -166,7 +169,7 @@ class ImagePanCommandBuilder(CommandBuilder):
if not isinstance(element, ImageData):
return False
old_crop = start_state.get('crop_info')
old_crop = start_state.get("crop_info")
if old_crop is None:
return False
@ -181,7 +184,7 @@ class ImagePanCommandBuilder(CommandBuilder):
if not isinstance(element, ImageData):
return None
old_crop = start_state.get('crop_info')
old_crop = start_state.get("crop_info")
if old_crop is None:
return None
@ -193,6 +196,7 @@ class ImagePanCommandBuilder(CommandBuilder):
return None
from pyPhotoAlbum.commands import AdjustImageCropCommand
command = AdjustImageCropCommand(element, old_crop, new_crop)
self.log_command("Image pan", f"{old_crop}{new_crop}")

View File

@ -12,7 +12,7 @@ from .interaction_command_builders import (
MoveCommandBuilder,
ResizeCommandBuilder,
RotateCommandBuilder,
ImagePanCommandBuilder
ImagePanCommandBuilder,
)
@ -31,10 +31,10 @@ class InteractionCommandFactory:
def _register_default_builders(self):
"""Register the default command builders."""
self.register_builder('move', MoveCommandBuilder())
self.register_builder('resize', ResizeCommandBuilder())
self.register_builder('rotate', RotateCommandBuilder())
self.register_builder('image_pan', ImagePanCommandBuilder())
self.register_builder("move", MoveCommandBuilder())
self.register_builder("resize", ResizeCommandBuilder())
self.register_builder("rotate", RotateCommandBuilder())
self.register_builder("image_pan", ImagePanCommandBuilder())
def register_builder(self, interaction_type: str, builder: CommandBuilder):
"""
@ -46,11 +46,9 @@ class InteractionCommandFactory:
"""
self._builders[interaction_type] = builder
def create_command(self,
interaction_type: str,
element: BaseLayoutElement,
start_state: dict,
**kwargs) -> Optional[Any]:
def create_command(
self, interaction_type: str, element: BaseLayoutElement, start_state: dict, **kwargs
) -> Optional[Any]:
"""
Create a command based on interaction type and state changes.
@ -91,13 +89,15 @@ class InteractionState:
the code more maintainable.
"""
def __init__(self,
element: Optional[BaseLayoutElement] = None,
interaction_type: Optional[str] = None,
position: Optional[tuple] = None,
size: Optional[tuple] = None,
rotation: Optional[float] = None,
crop_info: Optional[tuple] = None):
def __init__(
self,
element: Optional[BaseLayoutElement] = None,
interaction_type: Optional[str] = None,
position: Optional[tuple] = None,
size: Optional[tuple] = None,
rotation: Optional[float] = None,
crop_info: Optional[tuple] = None,
):
"""
Initialize interaction state.
@ -125,13 +125,13 @@ class InteractionState:
"""
state = {}
if self.position is not None:
state['position'] = self.position
state["position"] = self.position
if self.size is not None:
state['size'] = self.size
state["size"] = self.size
if self.rotation is not None:
state['rotation'] = self.rotation
state["rotation"] = self.rotation
if self.crop_info is not None:
state['crop_info'] = self.crop_info
state["crop_info"] = self.crop_info
return state
def is_valid(self) -> bool:

View File

@ -33,7 +33,7 @@ class UndoableInteractionMixin:
element: The element being moved
"""
self._interaction_state.element = element
self._interaction_state.interaction_type = 'move'
self._interaction_state.interaction_type = "move"
self._interaction_state.position = element.position
def _begin_resize(self, element: BaseLayoutElement):
@ -44,7 +44,7 @@ class UndoableInteractionMixin:
element: The element being resized
"""
self._interaction_state.element = element
self._interaction_state.interaction_type = 'resize'
self._interaction_state.interaction_type = "resize"
self._interaction_state.position = element.position
self._interaction_state.size = element.size
@ -56,7 +56,7 @@ class UndoableInteractionMixin:
element: The element being rotated
"""
self._interaction_state.element = element
self._interaction_state.interaction_type = 'rotate'
self._interaction_state.interaction_type = "rotate"
self._interaction_state.rotation = element.rotation
def _begin_image_pan(self, element):
@ -67,11 +67,12 @@ class UndoableInteractionMixin:
element: The ImageData element being panned
"""
from pyPhotoAlbum.models import ImageData
if not isinstance(element, ImageData):
return
self._interaction_state.element = element
self._interaction_state.interaction_type = 'image_pan'
self._interaction_state.interaction_type = "image_pan"
self._interaction_state.crop_info = element.crop_info
def _end_interaction(self):
@ -88,7 +89,7 @@ class UndoableInteractionMixin:
# Get main window to access project history
main_window = self.window()
if not hasattr(main_window, 'project'):
if not hasattr(main_window, "project"):
self._clear_interaction_state()
return
@ -96,7 +97,7 @@ class UndoableInteractionMixin:
command = self._command_factory.create_command(
interaction_type=self._interaction_state.interaction_type,
element=self._interaction_state.element,
start_state=self._interaction_state.to_dict()
start_state=self._interaction_state.to_dict(),
)
# Execute the command through history if one was created

View File

@ -16,6 +16,7 @@ def significant_change(threshold: float = 0.1):
Returns:
None if change is insignificant, otherwise returns the command builder result
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
@ -23,7 +24,9 @@ def significant_change(threshold: float = 0.1):
if result is None:
return None
return result
return wrapper
return decorator
@ -31,9 +34,9 @@ class ChangeValidator:
"""Validates whether changes are significant enough to create commands."""
@staticmethod
def position_changed(old_pos: Optional[Tuple[float, float]],
new_pos: Optional[Tuple[float, float]],
threshold: float = 0.1) -> bool:
def position_changed(
old_pos: Optional[Tuple[float, float]], new_pos: Optional[Tuple[float, float]], threshold: float = 0.1
) -> bool:
"""Check if position changed significantly."""
if old_pos is None or new_pos is None:
return False
@ -43,9 +46,9 @@ class ChangeValidator:
return dx > threshold or dy > threshold
@staticmethod
def size_changed(old_size: Optional[Tuple[float, float]],
new_size: Optional[Tuple[float, float]],
threshold: float = 0.1) -> bool:
def size_changed(
old_size: Optional[Tuple[float, float]], new_size: Optional[Tuple[float, float]], threshold: float = 0.1
) -> bool:
"""Check if size changed significantly."""
if old_size is None or new_size is None:
return False
@ -55,9 +58,7 @@ class ChangeValidator:
return dw > threshold or dh > threshold
@staticmethod
def rotation_changed(old_rotation: Optional[float],
new_rotation: Optional[float],
threshold: float = 0.1) -> bool:
def rotation_changed(old_rotation: Optional[float], new_rotation: Optional[float], threshold: float = 0.1) -> bool:
"""Check if rotation changed significantly."""
if old_rotation is None or new_rotation is None:
return False
@ -65,9 +66,11 @@ class ChangeValidator:
return abs(new_rotation - old_rotation) > threshold
@staticmethod
def crop_changed(old_crop: Optional[Tuple[float, float, float, float]],
new_crop: Optional[Tuple[float, float, float, float]],
threshold: float = 0.001) -> bool:
def crop_changed(
old_crop: Optional[Tuple[float, float, float, float]],
new_crop: Optional[Tuple[float, float, float, float]],
threshold: float = 0.001,
) -> bool:
"""Check if crop info changed significantly."""
if old_crop is None or new_crop is None:
return False
@ -85,8 +88,7 @@ class InteractionChangeDetector:
self.threshold = threshold
self.validator = ChangeValidator()
def detect_position_change(self, old_pos: Tuple[float, float],
new_pos: Tuple[float, float]) -> Optional[dict]:
def detect_position_change(self, old_pos: Tuple[float, float], new_pos: Tuple[float, float]) -> Optional[dict]:
"""
Detect position change and return change info.
@ -97,14 +99,13 @@ class InteractionChangeDetector:
return None
return {
'old_position': old_pos,
'new_position': new_pos,
'delta_x': new_pos[0] - old_pos[0],
'delta_y': new_pos[1] - old_pos[1]
"old_position": old_pos,
"new_position": new_pos,
"delta_x": new_pos[0] - old_pos[0],
"delta_y": new_pos[1] - old_pos[1],
}
def detect_size_change(self, old_size: Tuple[float, float],
new_size: Tuple[float, float]) -> Optional[dict]:
def detect_size_change(self, old_size: Tuple[float, float], new_size: Tuple[float, float]) -> Optional[dict]:
"""
Detect size change and return change info.
@ -115,14 +116,13 @@ class InteractionChangeDetector:
return None
return {
'old_size': old_size,
'new_size': new_size,
'delta_width': new_size[0] - old_size[0],
'delta_height': new_size[1] - old_size[1]
"old_size": old_size,
"new_size": new_size,
"delta_width": new_size[0] - old_size[0],
"delta_height": new_size[1] - old_size[1],
}
def detect_rotation_change(self, old_rotation: float,
new_rotation: float) -> Optional[dict]:
def detect_rotation_change(self, old_rotation: float, new_rotation: float) -> Optional[dict]:
"""
Detect rotation change and return change info.
@ -132,14 +132,11 @@ class InteractionChangeDetector:
if not self.validator.rotation_changed(old_rotation, new_rotation, self.threshold):
return None
return {
'old_rotation': old_rotation,
'new_rotation': new_rotation,
'delta_angle': new_rotation - old_rotation
}
return {"old_rotation": old_rotation, "new_rotation": new_rotation, "delta_angle": new_rotation - old_rotation}
def detect_crop_change(self, old_crop: Tuple[float, float, float, float],
new_crop: Tuple[float, float, float, float]) -> Optional[dict]:
def detect_crop_change(
self, old_crop: Tuple[float, float, float, float], new_crop: Tuple[float, float, float, float]
) -> Optional[dict]:
"""
Detect crop change and return change info.
@ -149,8 +146,4 @@ class InteractionChangeDetector:
if not self.validator.crop_changed(old_crop, new_crop, threshold=0.001):
return None
return {
'old_crop': old_crop,
'new_crop': new_crop,
'delta': tuple(new_crop[i] - old_crop[i] for i in range(4))
}
return {"old_crop": old_crop, "new_crop": new_crop, "delta": tuple(new_crop[i] - old_crop[i] for i in range(4))}

View File

@ -16,7 +16,7 @@ class KeyboardNavigationMixin:
def _navigate_to_next_page(self):
"""Navigate to the next page using Page Down key"""
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return
current_index = main_window._get_most_visible_page_index()
@ -24,14 +24,14 @@ class KeyboardNavigationMixin:
next_page = main_window.project.pages[current_index + 1]
self._scroll_to_page(next_page, current_index + 1)
if hasattr(main_window, 'show_status'):
if hasattr(main_window, "show_status"):
page_name = main_window.project.get_page_display_name(next_page)
main_window.show_status(f"Navigated to {page_name}", 2000)
def _navigate_to_previous_page(self):
"""Navigate to the previous page using Page Up key"""
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return
current_index = main_window._get_most_visible_page_index()
@ -39,7 +39,7 @@ class KeyboardNavigationMixin:
prev_page = main_window.project.pages[current_index - 1]
self._scroll_to_page(prev_page, current_index - 1)
if hasattr(main_window, 'show_status'):
if hasattr(main_window, "show_status"):
page_name = main_window.project.get_page_display_name(prev_page)
main_window.show_status(f"Navigated to {page_name}", 2000)
@ -52,7 +52,7 @@ class KeyboardNavigationMixin:
page_index: The index of the page in the project
"""
main_window = self.window()
if not hasattr(main_window, 'project'):
if not hasattr(main_window, "project"):
return
dpi = main_window.project.working_dpi
@ -79,14 +79,14 @@ class KeyboardNavigationMixin:
self.pan_offset[1] = target_pan_y
# Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'):
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.update()
# Update scrollbars if available
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
def _move_viewport_with_arrow_keys(self, key):
@ -109,14 +109,14 @@ class KeyboardNavigationMixin:
self.pan_offset[0] -= move_amount
# Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'):
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.update()
# Update scrollbars if available
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
def _move_selected_elements_with_arrow_keys(self, key):
@ -127,7 +127,7 @@ class KeyboardNavigationMixin:
key: The Qt key code (Up, Down, Left, Right)
"""
main_window = self.window()
if not hasattr(main_window, 'project'):
if not hasattr(main_window, "project"):
return
# Movement amount in mm
@ -151,7 +151,7 @@ class KeyboardNavigationMixin:
new_y = current_y + dy
# Apply snapping if element has a parent page
if hasattr(element, '_parent_page') and element._parent_page:
if hasattr(element, "_parent_page") and element._parent_page:
page = element._parent_page
snap_sys = page.layout.snapping_system
page_size = page.layout.size
@ -162,7 +162,7 @@ class KeyboardNavigationMixin:
size=element.size,
page_size=page_size,
dpi=dpi,
project=main_window.project
project=main_window.project,
)
element.position = snapped_pos
else:
@ -170,7 +170,7 @@ class KeyboardNavigationMixin:
self.update()
if hasattr(main_window, 'show_status'):
if hasattr(main_window, "show_status"):
count = len(self.selected_elements)
elem_text = "element" if count == 1 else "elements"
main_window.show_status(f"Moved {count} {elem_text}", 1000)

View File

@ -66,7 +66,9 @@ class MouseInteractionMixin:
element = self._get_element_at(x, y)
if element:
print(f"DEBUG: Clicked on element: {element}, ctrl_pressed: {ctrl_pressed}, shift_pressed: {shift_pressed}")
print(
f"DEBUG: Clicked on element: {element}, ctrl_pressed: {ctrl_pressed}, shift_pressed: {shift_pressed}"
)
# Check if Ctrl is pressed and element is ImageData - enter image pan mode
if ctrl_pressed and isinstance(element, ImageData) and not self.rotation_mode:
# Enter image pan mode - pan image within frame
@ -129,7 +131,7 @@ class MouseInteractionMixin:
self.pan_offset[1] += dy
# Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'):
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.drag_start_pos = (x, y)
@ -137,7 +139,7 @@ class MouseInteractionMixin:
# Update scrollbars if available
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
return
@ -152,7 +154,7 @@ class MouseInteractionMixin:
elif self.rotation_mode:
# Rotation mode
if not hasattr(self.selected_element, '_page_renderer'):
if not hasattr(self.selected_element, "_page_renderer"):
return
renderer = self.selected_element._page_renderer
@ -173,7 +175,7 @@ class MouseInteractionMixin:
self.selected_element.rotation = angle
main_window = self.window()
if hasattr(main_window, 'show_status'):
if hasattr(main_window, "show_status"):
main_window.show_status(f"Rotation: {angle:.1f}°", 100)
elif self.resize_handle:
@ -189,11 +191,13 @@ class MouseInteractionMixin:
# Move mode
current_page, current_page_index, current_renderer = self._get_page_at(x, y)
if current_page and hasattr(self.selected_element, '_parent_page'):
if current_page and hasattr(self.selected_element, "_parent_page"):
source_page = self.selected_element._parent_page
if current_page is not source_page:
self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer)
self._transfer_element_to_page(
self.selected_element, source_page, current_page, x, y, current_renderer
)
else:
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
@ -211,7 +215,7 @@ class MouseInteractionMixin:
size=self.selected_element.size,
page_size=page_size,
dpi=dpi,
project=main_window.project
project=main_window.project,
)
self.selected_element.position = snapped_pos
@ -238,11 +242,7 @@ class MouseInteractionMixin:
self.rotation_start_angle = None
self.image_pan_mode = False
self.image_pan_start_crop = None
self.snap_state = {
'is_snapped': False,
'last_position': None,
'last_size': None
}
self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None}
self.setCursor(Qt.CursorShape.ArrowCursor)
elif event.button() == Qt.MouseButton.MiddleButton:
@ -257,6 +257,7 @@ class MouseInteractionMixin:
element = self._get_element_at(x, y)
from pyPhotoAlbum.models import TextBoxData
if isinstance(element, TextBoxData):
self._edit_text_element(element)
return
@ -293,23 +294,20 @@ class MouseInteractionMixin:
if self.is_dragging and self.drag_start_pos:
pan_delta_x = self.pan_offset[0] - old_pan_x
pan_delta_y = self.pan_offset[1] - old_pan_y
self.drag_start_pos = (
self.drag_start_pos[0] + pan_delta_x,
self.drag_start_pos[1] + pan_delta_y
)
self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
# Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'):
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.update()
main_window = self.window()
if hasattr(main_window, 'status_bar'):
if hasattr(main_window, "status_bar"):
main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)
# Update scrollbars if available
if hasattr(main_window, 'update_scrollbars'):
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
else:
# Regular wheel: Vertical scroll
@ -318,22 +316,19 @@ class MouseInteractionMixin:
self.pan_offset[1] += scroll_amount
# Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'):
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
# If dragging, adjust drag_start_pos to account for pan_offset change
if self.is_dragging and self.drag_start_pos:
pan_delta_y = self.pan_offset[1] - old_pan_y
self.drag_start_pos = (
self.drag_start_pos[0],
self.drag_start_pos[1] + pan_delta_y
)
self.drag_start_pos = (self.drag_start_pos[0], self.drag_start_pos[1] + pan_delta_y)
self.update()
# Update scrollbars if available
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
def _edit_text_element(self, text_element):
@ -344,9 +339,9 @@ class MouseInteractionMixin:
if dialog.exec() == TextEditDialog.DialogCode.Accepted:
values = dialog.get_values()
text_element.text_content = values['text_content']
text_element.font_settings = values['font_settings']
text_element.alignment = values['alignment']
text_element.text_content = values["text_content"]
text_element.font_settings = values["font_settings"]
text_element.alignment = values["alignment"]
self.update()

View File

@ -15,15 +15,15 @@ from pyPhotoAlbum.mixins.operations.zorder_ops import ZOrderOperationsMixin
from pyPhotoAlbum.mixins.operations.merge_ops import MergeOperationsMixin
__all__ = [
'FileOperationsMixin',
'EditOperationsMixin',
'ElementOperationsMixin',
'PageOperationsMixin',
'TemplateOperationsMixin',
'ViewOperationsMixin',
'AlignmentOperationsMixin',
'DistributionOperationsMixin',
'SizeOperationsMixin',
'ZOrderOperationsMixin',
'MergeOperationsMixin',
"FileOperationsMixin",
"EditOperationsMixin",
"ElementOperationsMixin",
"PageOperationsMixin",
"TemplateOperationsMixin",
"ViewOperationsMixin",
"AlignmentOperationsMixin",
"DistributionOperationsMixin",
"SizeOperationsMixin",
"ZOrderOperationsMixin",
"MergeOperationsMixin",
]

View File

@ -20,7 +20,7 @@ class AlignmentOperationsMixin:
tab="Arrange",
group="Align",
requires_selection=True,
min_selection=2
min_selection=2,
)
def align_left(self):
"""Align selected elements to the left"""
@ -41,7 +41,7 @@ class AlignmentOperationsMixin:
tab="Arrange",
group="Align",
requires_selection=True,
min_selection=2
min_selection=2,
)
def align_right(self):
"""Align selected elements to the right"""
@ -62,7 +62,7 @@ class AlignmentOperationsMixin:
tab="Arrange",
group="Align",
requires_selection=True,
min_selection=2
min_selection=2,
)
def align_top(self):
"""Align selected elements to the top"""
@ -83,7 +83,7 @@ class AlignmentOperationsMixin:
tab="Arrange",
group="Align",
requires_selection=True,
min_selection=2
min_selection=2,
)
def align_bottom(self):
"""Align selected elements to the bottom"""
@ -104,7 +104,7 @@ class AlignmentOperationsMixin:
tab="Arrange",
group="Align",
requires_selection=True,
min_selection=2
min_selection=2,
)
def align_horizontal_center(self):
"""Align selected elements to horizontal center"""
@ -125,7 +125,7 @@ class AlignmentOperationsMixin:
tab="Arrange",
group="Align",
requires_selection=True,
min_selection=2
min_selection=2,
)
def align_vertical_center(self):
"""Align selected elements to vertical center"""
@ -146,7 +146,7 @@ class AlignmentOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=1
min_selection=1,
)
def maximize_pattern(self):
"""Maximize selected elements until they are close to borders or each other"""

View File

@ -20,7 +20,7 @@ class DistributionOperationsMixin:
tab="Arrange",
group="Distribute",
requires_selection=True,
min_selection=3
min_selection=3,
)
def distribute_horizontally(self):
"""Distribute selected elements evenly horizontally"""
@ -41,7 +41,7 @@ class DistributionOperationsMixin:
tab="Arrange",
group="Distribute",
requires_selection=True,
min_selection=3
min_selection=3,
)
def distribute_vertically(self):
"""Distribute selected elements evenly vertically"""
@ -62,7 +62,7 @@ class DistributionOperationsMixin:
tab="Arrange",
group="Distribute",
requires_selection=True,
min_selection=3
min_selection=3,
)
def space_horizontally(self):
"""Space selected elements equally horizontally"""
@ -83,7 +83,7 @@ class DistributionOperationsMixin:
tab="Arrange",
group="Distribute",
requires_selection=True,
min_selection=3
min_selection=3,
)
def space_vertically(self):
"""Space selected elements equally vertically"""

View File

@ -9,13 +9,7 @@ from pyPhotoAlbum.commands import DeleteElementCommand, RotateElementCommand
class EditOperationsMixin:
"""Mixin providing edit-related operations"""
@ribbon_action(
label="Undo",
tooltip="Undo last action (Ctrl+Z)",
tab="Home",
group="Edit",
shortcut="Ctrl+Z"
)
@ribbon_action(label="Undo", tooltip="Undo last action (Ctrl+Z)", tab="Home", group="Edit", shortcut="Ctrl+Z")
def undo(self):
"""Undo last action"""
if self.project.history.undo():
@ -27,11 +21,7 @@ class EditOperationsMixin:
print("Nothing to undo")
@ribbon_action(
label="Redo",
tooltip="Redo last action (Ctrl+Y or Ctrl+Shift+Z)",
tab="Home",
group="Edit",
shortcut="Ctrl+Y"
label="Redo", tooltip="Redo last action (Ctrl+Y or Ctrl+Shift+Z)", tab="Home", group="Edit", shortcut="Ctrl+Y"
)
def redo(self):
"""Redo last action"""
@ -49,7 +39,7 @@ class EditOperationsMixin:
tab="Home",
group="Edit",
shortcut="Delete",
requires_selection=True
requires_selection=True,
)
def delete_selected_element(self):
"""Delete the currently selected element"""
@ -65,11 +55,7 @@ class EditOperationsMixin:
selected_element = next(iter(self.gl_widget.selected_elements))
try:
cmd = DeleteElementCommand(
current_page.layout,
selected_element,
asset_manager=self.project.asset_manager
)
cmd = DeleteElementCommand(current_page.layout, selected_element, asset_manager=self.project.asset_manager)
self.project.history.execute(cmd)
# Clear selection
@ -90,7 +76,7 @@ class EditOperationsMixin:
tooltip="Rotate selected element 90° counter-clockwise",
tab="Home",
group="Transform",
requires_selection=True
requires_selection=True,
)
def rotate_left(self):
"""Rotate selected element 90 degrees counter-clockwise"""
@ -113,7 +99,7 @@ class EditOperationsMixin:
tooltip="Rotate selected element 90° clockwise",
tab="Home",
group="Transform",
requires_selection=True
requires_selection=True,
)
def rotate_right(self):
"""Rotate selected element 90 degrees clockwise"""
@ -136,7 +122,7 @@ class EditOperationsMixin:
tooltip="Reset selected element rotation to 0°",
tab="Home",
group="Transform",
requires_selection=True
requires_selection=True,
)
def reset_rotation(self):
"""Reset selected element rotation to 0 degrees"""

View File

@ -13,11 +13,7 @@ class ElementOperationsMixin:
"""Mixin providing element creation and manipulation operations"""
@ribbon_action(
label="Image",
tooltip="Add an image to the current page",
tab="Insert",
group="Media",
requires_page=True
label="Image", tooltip="Add an image to the current page", tab="Insert", group="Media", requires_page=True
)
def add_image(self):
"""Add an image to the current page"""
@ -25,10 +21,7 @@ class ElementOperationsMixin:
return
file_path, _ = QFileDialog.getOpenFileName(
self,
"Select Image",
"",
"Image Files (*.jpg *.jpeg *.png *.gif *.bmp *.tiff *.webp);;All Files (*)"
self, "Select Image", "", "Image Files (*.jpg *.jpeg *.png *.gif *.bmp *.tiff *.webp);;All Files (*)"
)
if not file_path:
@ -59,20 +52,10 @@ class ElementOperationsMixin:
x = (page_width_mm - img_width) / 2
y = (page_height_mm - img_height) / 2
new_image = ImageData(
image_path=asset_path,
x=x,
y=y,
width=img_width,
height=img_height
)
new_image = ImageData(image_path=asset_path, x=x, y=y, width=img_width, height=img_height)
# Add element using command pattern for undo/redo
cmd = AddElementCommand(
current_page.layout,
new_image,
asset_manager=self.project.asset_manager
)
cmd = AddElementCommand(current_page.layout, new_image, asset_manager=self.project.asset_manager)
self.project.history.execute(cmd)
self.update_view()
@ -84,11 +67,7 @@ class ElementOperationsMixin:
print(f"Error adding image: {e}")
@ribbon_action(
label="Text",
tooltip="Add a text box to the current page",
tab="Insert",
group="Media",
requires_page=True
label="Text", tooltip="Add a text box to the current page", tab="Insert", group="Media", requires_page=True
)
def add_text(self):
"""Add text to the current page"""
@ -110,13 +89,7 @@ class ElementOperationsMixin:
x = (page_width_mm - text_width) / 2
y = (page_height_mm - text_height) / 2
new_text = TextBoxData(
text_content="New Text",
x=x,
y=y,
width=text_width,
height=text_height
)
new_text = TextBoxData(text_content="New Text", x=x, y=y, width=text_width, height=text_height)
current_page.layout.add_element(new_text)
self.update_view()
@ -128,7 +101,7 @@ class ElementOperationsMixin:
tooltip="Add a placeholder to the current page",
tab="Insert",
group="Media",
requires_page=True
requires_page=True,
)
def add_placeholder(self):
"""Add a placeholder to the current page"""
@ -151,11 +124,7 @@ class ElementOperationsMixin:
y = (page_height_mm - placeholder_height) / 2
new_placeholder = PlaceholderData(
placeholder_type="image",
x=x,
y=y,
width=placeholder_width,
height=placeholder_height
placeholder_type="image", x=x, y=y, width=placeholder_width, height=placeholder_height
)
current_page.layout.add_element(new_placeholder)

View File

@ -5,9 +5,19 @@ File operations mixin for pyPhotoAlbum
import os
from PyQt6.QtWidgets import (
QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton,
QButtonGroup, QLineEdit, QTextEdit
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, Page
@ -22,13 +32,7 @@ from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
class FileOperationsMixin:
"""Mixin providing file-related operations"""
@ribbon_action(
label="New",
tooltip="Create a new project",
tab="Home",
group="File",
shortcut="Ctrl+N"
)
@ribbon_action(label="New", tooltip="Create a new project", tab="Home", group="File", shortcut="Ctrl+N")
def new_project(self):
"""Create a new project with initial setup dialog"""
# Create new project setup dialog
@ -153,7 +157,7 @@ class FileOperationsMixin:
export_dpi = export_dpi_spinbox.value()
# Cleanup old project if it exists
if hasattr(self, 'project') and self.project:
if hasattr(self, "project") and self.project:
self.project.cleanup()
# Create project with custom settings
@ -174,27 +178,18 @@ class FileOperationsMixin:
# User cancelled - keep current project
print("New project creation cancelled")
@ribbon_action(
label="Open",
tooltip="Open an existing project",
tab="Home",
group="File",
shortcut="Ctrl+O"
)
@ribbon_action(label="Open", tooltip="Open an existing project", tab="Home", group="File", shortcut="Ctrl+O")
def open_project(self):
"""Open an existing project with async loading and progress bar"""
file_path, _ = QFileDialog.getOpenFileName(
self,
"Open Project",
"",
"pyPhotoAlbum Projects (*.ppz);;All Files (*)"
self, "Open Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)"
)
if file_path:
print(f"Opening project: {file_path}")
# Create loading widget if not exists
if not hasattr(self, '_loading_widget'):
if not hasattr(self, "_loading_widget"):
self._loading_widget = LoadingWidget(self)
# Show loading widget
@ -214,29 +209,29 @@ class FileOperationsMixin:
def _on_load_progress(self, current: int, total: int, message: str):
"""Handle loading progress updates"""
if hasattr(self, '_loading_widget'):
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:
if hasattr(self, "project") and self.project:
self.project.cleanup()
# Set new project
self.project = project
# Set file path and mark as clean
if hasattr(self, '_opening_file_path'):
if hasattr(self, "_opening_file_path"):
self.project.file_path = self._opening_file_path
delattr(self, '_opening_file_path')
delattr(self, "_opening_file_path")
self.project.mark_clean()
self.gl_widget.current_page_index = 0 # Reset to first page
# Hide loading widget
if hasattr(self, '_loading_widget'):
if hasattr(self, "_loading_widget"):
self._loading_widget.hide_loading()
# Update view (this will trigger progressive image loading)
@ -254,7 +249,7 @@ class FileOperationsMixin:
def _on_load_failed(self, error_msg: str):
"""Handle project load failure"""
# Hide loading widget
if hasattr(self, '_loading_widget'):
if hasattr(self, "_loading_widget"):
self._loading_widget.hide_loading()
error_msg = f"Failed to open project: {error_msg}"
@ -262,24 +257,15 @@ class FileOperationsMixin:
self.show_error("Load Failed", error_msg)
print(error_msg)
@ribbon_action(
label="Save",
tooltip="Save the current project",
tab="Home",
group="File",
shortcut="Ctrl+S"
)
@ribbon_action(label="Save", tooltip="Save the current project", tab="Home", group="File", shortcut="Ctrl+S")
def save_project(self):
"""Save the current project"""
# If project has a file path, use it; otherwise prompt for location
file_path = self.project.file_path if hasattr(self.project, 'file_path') and self.project.file_path else None
file_path = self.project.file_path if hasattr(self.project, "file_path") and self.project.file_path else None
if not file_path:
file_path, _ = QFileDialog.getSaveFileName(
self,
"Save Project",
"",
"pyPhotoAlbum Projects (*.ppz);;All Files (*)"
self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)"
)
if file_path:
@ -298,12 +284,7 @@ class FileOperationsMixin:
self.show_status(error_msg)
print(error_msg)
@ribbon_action(
label="Heal Assets",
tooltip="Reconnect missing image assets",
tab="Home",
group="File"
)
@ribbon_action(label="Heal Assets", tooltip="Reconnect missing image assets", tab="Home", group="File")
def heal_assets(self):
"""Open the asset healing dialog to reconnect missing images"""
dialog = AssetHealDialog(self.project, self)
@ -348,10 +329,12 @@ class FileOperationsMixin:
msg.setWindowTitle("Missing Assets")
msg.setText(f"{len(missing_assets)} image(s) could not be found in the assets folder:")
msg.setInformativeText(asset_list)
msg.setDetailedText("These images need to be reconnected using the 'Heal Assets' feature.\n\n"
"Go to: Home → Heal Assets\n\n"
"Add search paths where the original images might be located, "
"then click 'Attempt Healing' to find and import them.")
msg.setDetailedText(
"These images need to be reconnected using the 'Heal Assets' feature.\n\n"
"Go to: Home → Heal Assets\n\n"
"Add search paths where the original images might be located, "
"then click 'Attempt Healing' to find and import them."
)
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
msg.button(QMessageBox.StandardButton.Open).setText("Open Heal Assets")
@ -360,17 +343,9 @@ class FileOperationsMixin:
self.heal_assets()
@ribbon_action(
label="Project Settings",
tooltip="Configure project-wide page size and defaults",
tab="Home",
group="File"
)
@numerical_input(
fields=[
('width', 'Width', 'mm', 10, 1000),
('height', 'Height', 'mm', 10, 1000)
]
label="Project Settings", tooltip="Configure project-wide page size and defaults", tab="Home", group="File"
)
@numerical_input(fields=[("width", "Width", "mm", 10, 1000), ("height", "Height", "mm", 10, 1000)])
def project_settings(self):
"""Configure project-wide settings including default page size"""
# Create dialog
@ -440,7 +415,9 @@ class FileOperationsMixin:
scaling_group = QGroupBox("Apply to Existing Pages")
scaling_layout = QVBoxLayout()
info_label = QLabel("How should existing content be adjusted?\n(Pages with manual sizing will not be affected)")
info_label = QLabel(
"How should existing content be adjusted?\n(Pages with manual sizing will not be affected)"
)
info_label.setWordWrap(True)
scaling_layout.addWidget(info_label)
@ -494,11 +471,11 @@ class FileOperationsMixin:
new_export_dpi = export_dpi_spinbox.value()
# Determine scaling mode
scaling_mode = 'none'
scaling_mode = "none"
if scaling_buttons:
selected_id = scaling_buttons.checkedId()
modes = {0: 'proportional', 1: 'stretch', 2: 'reposition', 3: 'none'}
scaling_mode = modes.get(selected_id, 'none')
modes = {0: "proportional", 1: "stretch", 2: "reposition", 3: "none"}
scaling_mode = modes.get(selected_id, "none")
# Apply settings
old_size = self.project.page_size_mm
@ -544,14 +521,14 @@ class FileOperationsMixin:
page.layout.size = (new_width, new_height)
# Apply content scaling based on mode
if scaling_mode == 'proportional':
if scaling_mode == "proportional":
# Use smallest ratio to fit content
scale = min(width_ratio, height_ratio)
self._scale_page_elements(page, scale, scale)
elif scaling_mode == 'stretch':
elif scaling_mode == "stretch":
# Scale independently on each axis
self._scale_page_elements(page, width_ratio, height_ratio)
elif scaling_mode == 'reposition':
elif scaling_mode == "reposition":
# Keep size, center content
self._reposition_page_elements(page, old_size, new_size)
# 'none' - do nothing to elements
@ -593,12 +570,7 @@ class FileOperationsMixin:
x, y = element.position
element.position = (x + x_offset, y + y_offset)
@ribbon_action(
label="Export PDF",
tooltip="Export project to PDF",
tab="Export",
group="Export"
)
@ribbon_action(label="Export PDF", tooltip="Export project to PDF", tab="Export", group="Export")
def export_pdf(self):
"""Export project to PDF using async backend (non-blocking)"""
# Check if we have pages to export
@ -607,19 +579,14 @@ class FileOperationsMixin:
return
# Show file save dialog
file_path, _ = QFileDialog.getSaveFileName(
self,
"Export to PDF",
"",
"PDF Files (*.pdf);;All Files (*)"
)
file_path, _ = QFileDialog.getSaveFileName(self, "Export to PDF", "", "PDF Files (*.pdf);;All Files (*)")
if not file_path:
return
# Ensure .pdf extension
if not file_path.lower().endswith('.pdf'):
file_path += '.pdf'
if not file_path.lower().endswith(".pdf"):
file_path += ".pdf"
# Use async PDF export (non-blocking, UI stays responsive)
success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=300)
@ -628,12 +595,7 @@ class FileOperationsMixin:
else:
self.show_status("PDF export failed to start", 3000)
@ribbon_action(
label="About",
tooltip="About pyPhotoAlbum and data format version",
tab="Home",
group="File"
)
@ribbon_action(label="About", tooltip="About pyPhotoAlbum and data format version", tab="Home", group="File")
def show_about(self):
"""Show about dialog with version information"""
dialog = QDialog(self)

View File

@ -20,7 +20,7 @@ class MergeOperationsMixin:
label="Merge Projects",
tooltip="Merge another project file with the current project",
tab="File",
group="Import/Export"
group="Import/Export",
)
def merge_projects(self):
"""
@ -35,22 +35,19 @@ class MergeOperationsMixin:
self,
"Unsaved Changes",
"You have unsaved changes in the current project. Save before merging?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,
)
if reply == QMessageBox.StandardButton.Cancel:
return
elif reply == QMessageBox.StandardButton.Yes:
# Save current project first
if hasattr(self, 'save_project'):
if hasattr(self, "save_project"):
self.save_project()
# Select file to merge
file_path, _ = QFileDialog.getOpenFileName(
self,
"Select Project to Merge",
"",
"Photo Album Projects (*.ppz);;All Files (*)"
self, "Select Project to Merge", "", "Photo Album Projects (*.ppz);;All Files (*)"
)
if not file_path:
@ -58,7 +55,7 @@ class MergeOperationsMixin:
try:
# Disable autosave during merge
if hasattr(self, '_autosave_timer'):
if hasattr(self, "_autosave_timer"):
self._autosave_timer.stop()
# Load the other project
@ -82,14 +79,10 @@ class MergeOperationsMixin:
self._perform_concatenation(our_data, their_data)
except Exception as e:
QMessageBox.critical(
self,
"Merge Error",
f"Failed to merge projects:\n{str(e)}"
)
QMessageBox.critical(self, "Merge Error", f"Failed to merge projects:\n{str(e)}")
finally:
# Re-enable autosave
if hasattr(self, '_autosave_timer'):
if hasattr(self, "_autosave_timer"):
self._autosave_timer.start()
def _perform_merge_with_conflicts(self, our_data, their_data):
@ -104,7 +97,7 @@ class MergeOperationsMixin:
self,
"No Conflicts",
"No conflicts detected. Merge projects automatically?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
@ -117,11 +110,7 @@ class MergeOperationsMixin:
dialog = MergeDialog(our_data, their_data, self)
if dialog.exec() != QMessageBox.DialogCode.Accepted:
QMessageBox.information(
self,
"Merge Cancelled",
"Merge operation cancelled."
)
QMessageBox.information(self, "Merge Cancelled", "Merge operation cancelled.")
return
# Get merged data from dialog
@ -135,7 +124,7 @@ class MergeOperationsMixin:
"Merge Complete",
f"Projects merged successfully.\n"
f"Total pages: {len(merged_data.get('pages', []))}\n"
f"Resolved conflicts: {len(conflicts)}"
f"Resolved conflicts: {len(conflicts)}",
)
def _perform_concatenation(self, our_data, their_data):
@ -147,7 +136,7 @@ class MergeOperationsMixin:
f"{our_data.get('name', 'Untitled')}\n"
f"{their_data.get('name', 'Untitled')}\n\n"
f"Concatenate them (combine all pages)?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
@ -162,8 +151,7 @@ class MergeOperationsMixin:
QMessageBox.information(
self,
"Concatenation Complete",
f"Projects concatenated successfully.\n"
f"Total pages: {len(merged_data.get('pages', []))}"
f"Projects concatenated successfully.\n" f"Total pages: {len(merged_data.get('pages', []))}",
)
def _apply_merged_data(self, merged_data):
@ -182,9 +170,9 @@ class MergeOperationsMixin:
new_project.mark_dirty()
# Update UI
if hasattr(self, 'gl_widget'):
if hasattr(self, "gl_widget"):
self.gl_widget.set_project(new_project)
self.gl_widget.update()
if hasattr(self, 'status_bar'):
if hasattr(self, "status_bar"):
self.status_bar.showMessage("Merge completed successfully", 3000)

View File

@ -14,12 +14,7 @@ class PageOperationsMixin:
# Note: Previous/Next page navigation removed - now using scrollable multi-page view
# User can scroll through all pages vertically
@ribbon_action(
label="Add Page",
tooltip="Add a new page to the project",
tab="Layout",
group="Page"
)
@ribbon_action(label="Add Page", tooltip="Add a new page to the project", tab="Layout", group="Page")
def add_page(self):
"""Add a new page to the project after the current page"""
# Get the most visible page in viewport to determine insertion point
@ -73,12 +68,7 @@ class PageOperationsMixin:
new_page_name = self.project.get_page_display_name(new_page)
print(f"Added {new_page_name} at position {insert_index + 1} with size {width_mm}×{height_mm} mm")
@ribbon_action(
label="Page Setup",
tooltip="Configure page size and settings",
tab="Layout",
group="Page"
)
@ribbon_action(label="Page Setup", tooltip="Configure page size and settings", tab="Layout", group="Page")
@dialog_action(dialog_class=PageSetupDialog, requires_pages=True)
def page_setup(self, values):
"""
@ -90,17 +80,17 @@ class PageOperationsMixin:
Args:
values: Dictionary of values from the dialog
"""
selected_page = values['selected_page']
selected_index = values['selected_index']
selected_page = values["selected_page"]
selected_index = values["selected_index"]
# Update project cover settings
self.project.paper_thickness_mm = values['paper_thickness_mm']
self.project.cover_bleed_mm = values['cover_bleed_mm']
self.project.paper_thickness_mm = values["paper_thickness_mm"]
self.project.cover_bleed_mm = values["cover_bleed_mm"]
# Handle cover designation (only for first page)
if selected_index == 0:
was_cover = selected_page.is_cover
is_cover = values['is_cover']
is_cover = values["is_cover"]
if was_cover != is_cover:
selected_page.is_cover = is_cover
@ -116,8 +106,8 @@ class PageOperationsMixin:
print(f"Cover removed from page 1")
# Get new values
width_mm = values['width_mm']
height_mm = values['height_mm']
width_mm = values["width_mm"]
height_mm = values["height_mm"]
# Don't allow manual size changes for covers
if not selected_page.is_cover:
@ -126,11 +116,11 @@ class PageOperationsMixin:
if selected_page.is_double_spread:
old_base_width = (
selected_page.layout.base_width
if hasattr(selected_page.layout, 'base_width')
if hasattr(selected_page.layout, "base_width")
else selected_page.layout.size[0] / 2
)
old_height = selected_page.layout.size[1]
size_changed = (old_base_width != width_mm or old_height != height_mm)
size_changed = old_base_width != width_mm or old_height != height_mm
if size_changed:
# Update double spread
@ -143,7 +133,7 @@ class PageOperationsMixin:
)
else:
old_size = selected_page.layout.size
size_changed = (old_size != (width_mm, height_mm))
size_changed = old_size != (width_mm, height_mm)
if size_changed:
# Update single page
@ -151,16 +141,15 @@ class PageOperationsMixin:
selected_page.layout.base_width = width_mm
selected_page.manually_sized = True
print(
f"{self.project.get_page_display_name(selected_page)} "
f"updated to {width_mm}×{height_mm} mm"
f"{self.project.get_page_display_name(selected_page)} " f"updated to {width_mm}×{height_mm} mm"
)
# Update DPI settings
self.project.working_dpi = values['working_dpi']
self.project.export_dpi = values['export_dpi']
self.project.working_dpi = values["working_dpi"]
self.project.export_dpi = values["export_dpi"]
# Set as default if checkbox is checked
if values['set_as_default']:
if values["set_as_default"]:
self.project.page_size_mm = (width_mm, height_mm)
print(f"Project default page size set to {width_mm}×{height_mm} mm")
@ -172,15 +161,12 @@ class PageOperationsMixin:
status_msg = f"{page_name} updated"
else:
status_msg = f"{page_name} size: {width_mm}×{height_mm} mm"
if values['set_as_default']:
if values["set_as_default"]:
status_msg += " (set as default)"
self.show_status(status_msg, 2000)
@ribbon_action(
label="Toggle Spread",
tooltip="Toggle double page spread for current page",
tab="Layout",
group="Page"
label="Toggle Spread", tooltip="Toggle double page spread for current page", tab="Layout", group="Page"
)
def toggle_double_spread(self):
"""Toggle double spread for the current page"""
@ -208,7 +194,7 @@ class PageOperationsMixin:
current_height = current_page.layout.size[1]
# Get base width (might already be doubled)
if hasattr(current_page.layout, 'base_width'):
if hasattr(current_page.layout, "base_width"):
base_width = current_page.layout.base_width
else:
# Assume current width is single if not marked as facing
@ -228,12 +214,7 @@ class PageOperationsMixin:
self.show_status(f"{page_name}: Double spread {status}, width = {new_width:.0f}mm", 2000)
print(f"{page_name}: Double spread {status}, width = {new_width}mm")
@ribbon_action(
label="Remove Page",
tooltip="Remove the currently selected page",
tab="Layout",
group="Page"
)
@ribbon_action(label="Remove Page", tooltip="Remove the currently selected page", tab="Layout", group="Page")
def remove_page(self):
"""Remove the currently selected page"""
if len(self.project.pages) <= 1:

View File

@ -20,7 +20,7 @@ class SizeOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=2
min_selection=2,
)
def make_same_size(self):
"""Make all selected elements the same size"""
@ -41,7 +41,7 @@ class SizeOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=2
min_selection=2,
)
def make_same_width(self):
"""Make all selected elements the same width"""
@ -62,7 +62,7 @@ class SizeOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=2
min_selection=2,
)
def make_same_height(self):
"""Make all selected elements the same height"""
@ -83,7 +83,7 @@ class SizeOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=1
min_selection=1,
)
def fit_to_width(self):
"""Fit selected element to page width"""
@ -114,7 +114,7 @@ class SizeOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=1
min_selection=1,
)
def fit_to_height(self):
"""Fit selected element to page height"""
@ -145,7 +145,7 @@ class SizeOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=1
min_selection=1,
)
def fit_to_page(self):
"""Fit selected element to page dimensions"""
@ -176,7 +176,7 @@ class SizeOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=1
min_selection=1,
)
def expand_image(self):
"""Expand selected image to fill available space"""
@ -195,16 +195,11 @@ class SizeOperationsMixin:
other_elements = [e for e in page.layout.elements if e is not element]
# Use configurable min_gap (grid spacing from snapping system, default 10mm)
min_gap = getattr(page.layout.snapping_system, 'grid_spacing', 10.0)
min_gap = getattr(page.layout.snapping_system, "grid_spacing", 10.0)
# Expand to bounds
page_width, page_height = page.layout.size
change = AlignmentManager.expand_to_bounds(
element,
(page_width, page_height),
other_elements,
min_gap
)
change = AlignmentManager.expand_to_bounds(element, (page_width, page_height), other_elements, min_gap)
if change:
cmd = ResizeElementsCommand([change])

View File

@ -3,9 +3,16 @@ Template operations mixin for pyPhotoAlbum
"""
from PyQt6.QtWidgets import (
QInputDialog, QDialog, QVBoxLayout, QLabel, QComboBox,
QRadioButton, QButtonGroup, QPushButton, QHBoxLayout,
QDoubleSpinBox
QInputDialog,
QDialog,
QVBoxLayout,
QLabel,
QComboBox,
QRadioButton,
QButtonGroup,
QPushButton,
QHBoxLayout,
QDoubleSpinBox,
)
from pyPhotoAlbum.decorators import ribbon_action, undoable_operation
@ -18,7 +25,7 @@ class TemplateOperationsMixin:
tooltip="Save current page as a reusable template",
tab="Layout",
group="Templates",
requires_page=True
requires_page=True,
)
def save_page_as_template(self):
"""Save current page as a template"""
@ -36,37 +43,26 @@ class TemplateOperationsMixin:
self,
"Save Template",
"Enter template name:",
text=f"Template_{len(self.template_manager.list_templates()) + 1}"
text=f"Template_{len(self.template_manager.list_templates()) + 1}",
)
if not ok or not name:
return
# Ask for optional description
description, ok = QInputDialog.getText(
self,
"Template Description",
"Enter description (optional):"
)
description, ok = QInputDialog.getText(self, "Template Description", "Enter description (optional):")
if not ok:
description = ""
try:
# Create template from page
template = self.template_manager.create_template_from_page(
current_page,
name,
description
)
template = self.template_manager.create_template_from_page(current_page, name, description)
# Save template
self.template_manager.save_template(template)
self.show_info(
"Template Saved",
f"Template '{name}' has been saved successfully."
)
self.show_info("Template Saved", f"Template '{name}' has been saved successfully.")
print(f"Saved template: {name}")
@ -75,10 +71,7 @@ class TemplateOperationsMixin:
print(f"Error saving template: {e}")
@ribbon_action(
label="New from Template",
tooltip="Create a new page from a template",
tab="Layout",
group="Templates"
label="New from Template", tooltip="Create a new page from a template", tab="Layout", group="Templates"
)
def new_page_from_template(self):
"""Create a new page from a template"""
@ -87,8 +80,7 @@ class TemplateOperationsMixin:
if not templates:
self.show_info(
"No Templates",
"No templates available. Create a template first by using 'Save as Template'."
"No Templates", "No templates available. Create a template first by using 'Save as Template'."
)
return
@ -178,7 +170,7 @@ class TemplateOperationsMixin:
page_number=new_page_number,
target_size_mm=self.project.page_size_mm,
scale_mode=scale_mode,
margin_percent=margin_percent
margin_percent=margin_percent,
)
# Add to project
@ -200,9 +192,9 @@ class TemplateOperationsMixin:
tooltip="Apply a template layout to current page",
tab="Layout",
group="Templates",
requires_page=True
requires_page=True,
)
@undoable_operation(capture='page_elements', description='Apply Template')
@undoable_operation(capture="page_elements", description="Apply Template")
def apply_template_to_page(self):
"""Apply a template to the current page"""
current_page = self.get_current_page()
@ -214,8 +206,7 @@ class TemplateOperationsMixin:
if not templates:
self.show_info(
"No Templates",
"No templates available. Create a template first by using 'Save as Template'."
"No Templates", "No templates available. Create a template first by using 'Save as Template'."
)
return
@ -320,11 +311,7 @@ class TemplateOperationsMixin:
# Apply template to page
self.template_manager.apply_template_to_page(
template,
current_page,
mode=mode,
scale_mode=scale_mode,
margin_percent=margin_percent
template, current_page, mode=mode, scale_mode=scale_mode, margin_percent=margin_percent
)
# Update display

View File

@ -8,13 +8,7 @@ from pyPhotoAlbum.decorators import ribbon_action
class ViewOperationsMixin:
"""Mixin providing view-related operations"""
@ribbon_action(
label="Zoom In",
tooltip="Zoom in",
tab="View",
group="Zoom",
shortcut="Ctrl++"
)
@ribbon_action(label="Zoom In", tooltip="Zoom in", tab="View", group="Zoom", shortcut="Ctrl++")
def zoom_in(self):
"""Zoom in"""
self.gl_widget.zoom_level *= 1.2
@ -23,13 +17,7 @@ class ViewOperationsMixin:
self.update_view()
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
@ribbon_action(
label="Zoom Out",
tooltip="Zoom out",
tab="View",
group="Zoom",
shortcut="Ctrl+-"
)
@ribbon_action(label="Zoom Out", tooltip="Zoom out", tab="View", group="Zoom", shortcut="Ctrl+-")
def zoom_out(self):
"""Zoom out"""
self.gl_widget.zoom_level /= 1.2
@ -38,13 +26,7 @@ class ViewOperationsMixin:
self.update_view()
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
@ribbon_action(
label="Fit to Window",
tooltip="Fit page to window",
tab="View",
group="Zoom",
shortcut="Ctrl+0"
)
@ribbon_action(label="Fit to Window", tooltip="Fit page to window", tab="View", group="Zoom", shortcut="Ctrl+0")
def zoom_fit(self):
"""Fit page to window"""
if not self.project.pages:
@ -72,12 +54,7 @@ class ViewOperationsMixin:
self.update_view()
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
@ribbon_action(
label="Toggle Grid Snap",
tooltip="Toggle snapping to grid",
tab="View",
group="Snapping"
)
@ribbon_action(label="Toggle Grid Snap", tooltip="Toggle snapping to grid", tab="View", group="Snapping")
def toggle_grid_snap(self):
"""Toggle grid snapping"""
if not self.project:
@ -90,12 +67,7 @@ class ViewOperationsMixin:
self.show_status(f"Grid snapping {status}", 2000)
print(f"Grid snapping {status}")
@ribbon_action(
label="Toggle Edge Snap",
tooltip="Toggle snapping to page edges",
tab="View",
group="Snapping"
)
@ribbon_action(label="Toggle Edge Snap", tooltip="Toggle snapping to page edges", tab="View", group="Snapping")
def toggle_edge_snap(self):
"""Toggle edge snapping"""
if not self.project:
@ -108,12 +80,7 @@ class ViewOperationsMixin:
self.show_status(f"Edge snapping {status}", 2000)
print(f"Edge snapping {status}")
@ribbon_action(
label="Toggle Guide Snap",
tooltip="Toggle snapping to guides",
tab="View",
group="Snapping"
)
@ribbon_action(label="Toggle Guide Snap", tooltip="Toggle snapping to guides", tab="View", group="Snapping")
def toggle_guide_snap(self):
"""Toggle guide snapping"""
if not self.project:
@ -126,12 +93,7 @@ class ViewOperationsMixin:
self.show_status(f"Guide snapping {status}", 2000)
print(f"Guide snapping {status}")
@ribbon_action(
label="Show Grid",
tooltip="Toggle visibility of grid lines",
tab="View",
group="Snapping"
)
@ribbon_action(label="Show Grid", tooltip="Toggle visibility of grid lines", tab="View", group="Snapping")
def toggle_show_grid(self):
"""Toggle grid visibility"""
if not self.project:
@ -144,12 +106,7 @@ class ViewOperationsMixin:
self.show_status(f"Grid {status}", 2000)
print(f"Grid {status}")
@ribbon_action(
label="Show Guides",
tooltip="Toggle visibility of guide lines",
tab="View",
group="Snapping"
)
@ribbon_action(label="Show Guides", tooltip="Toggle visibility of guide lines", tab="View", group="Snapping")
def toggle_snap_lines(self):
"""Toggle guide lines visibility"""
if not self.project:
@ -162,12 +119,7 @@ class ViewOperationsMixin:
self.show_status(f"Guides {status}", 2000)
print(f"Guides {status}")
@ribbon_action(
label="Add H Guide",
tooltip="Add horizontal guide at page center",
tab="View",
group="Guides"
)
@ribbon_action(label="Add H Guide", tooltip="Add horizontal guide at page center", tab="View", group="Guides")
def add_horizontal_guide(self):
"""Add a horizontal guide at page center"""
current_page = self.get_current_page()
@ -176,18 +128,13 @@ class ViewOperationsMixin:
# Add guide at vertical center (in mm)
center_y = current_page.layout.size[1] / 2.0
current_page.layout.snapping_system.add_guide(center_y, 'horizontal')
current_page.layout.snapping_system.add_guide(center_y, "horizontal")
self.update_view()
self.show_status(f"Added horizontal guide at {center_y:.1f} mm", 2000)
print(f"Added horizontal guide at {center_y:.1f} mm")
@ribbon_action(
label="Add V Guide",
tooltip="Add vertical guide at page center",
tab="View",
group="Guides"
)
@ribbon_action(label="Add V Guide", tooltip="Add vertical guide at page center", tab="View", group="Guides")
def add_vertical_guide(self):
"""Add a vertical guide at page center"""
current_page = self.get_current_page()
@ -196,18 +143,13 @@ class ViewOperationsMixin:
# Add guide at horizontal center (in mm)
center_x = current_page.layout.size[0] / 2.0
current_page.layout.snapping_system.add_guide(center_x, 'vertical')
current_page.layout.snapping_system.add_guide(center_x, "vertical")
self.update_view()
self.show_status(f"Added vertical guide at {center_x:.1f} mm", 2000)
print(f"Added vertical guide at {center_x:.1f} mm")
@ribbon_action(
label="Clear Guides",
tooltip="Clear all guides from current page",
tab="View",
group="Guides"
)
@ribbon_action(label="Clear Guides", tooltip="Clear all guides from current page", tab="View", group="Guides")
def clear_guides(self):
"""Clear all guides from current page"""
current_page = self.get_current_page()
@ -222,10 +164,7 @@ class ViewOperationsMixin:
print(f"Cleared {guide_count} guides")
@ribbon_action(
label="Set Grid Size...",
tooltip="Configure grid spacing for snapping",
tab="View",
group="Snapping"
label="Set Grid Size...", tooltip="Configure grid spacing for snapping", tab="View", group="Snapping"
)
def set_grid_size(self):
"""Open dialog to set grid size"""
@ -304,7 +243,7 @@ class ViewOperationsMixin:
tooltip="Enable/disable snapping to grid (Ctrl+G)",
tab="Layout",
group="Snapping",
shortcut="Ctrl+G"
shortcut="Ctrl+G",
)
def layout_toggle_grid_snap(self):
"""Toggle grid snapping (Layout tab)"""
@ -315,47 +254,29 @@ class ViewOperationsMixin:
tooltip="Enable/disable snapping to page edges (Ctrl+E)",
tab="Layout",
group="Snapping",
shortcut="Ctrl+E"
shortcut="Ctrl+E",
)
def layout_toggle_edge_snap(self):
"""Toggle edge snapping (Layout tab)"""
self.toggle_edge_snap()
@ribbon_action(
label="Guide Snap",
tooltip="Enable/disable snapping to guides",
tab="Layout",
group="Snapping"
)
@ribbon_action(label="Guide Snap", tooltip="Enable/disable snapping to guides", tab="Layout", group="Snapping")
def layout_toggle_guide_snap(self):
"""Toggle guide snapping (Layout tab)"""
self.toggle_guide_snap()
@ribbon_action(
label="Show Grid",
tooltip="Toggle visibility of grid lines",
tab="Layout",
group="Snapping"
)
@ribbon_action(label="Show Grid", tooltip="Toggle visibility of grid lines", tab="Layout", group="Snapping")
def layout_toggle_show_grid(self):
"""Toggle grid visibility (Layout tab)"""
self.toggle_show_grid()
@ribbon_action(
label="Show Guides",
tooltip="Toggle visibility of guide lines",
tab="Layout",
group="Snapping"
)
@ribbon_action(label="Show Guides", tooltip="Toggle visibility of guide lines", tab="Layout", group="Snapping")
def layout_toggle_snap_lines(self):
"""Toggle guide lines visibility (Layout tab)"""
self.toggle_snap_lines()
@ribbon_action(
label="Grid Settings...",
tooltip="Configure grid size and snap threshold",
tab="Layout",
group="Snapping"
label="Grid Settings...", tooltip="Configure grid size and snap threshold", tab="Layout", group="Snapping"
)
def layout_set_grid_size(self):
"""Open grid settings dialog (Layout tab)"""

View File

@ -15,7 +15,7 @@ class ZOrderOperationsMixin:
tab="Arrange",
group="Order",
shortcut="Ctrl+Shift+]",
requires_selection=True
requires_selection=True,
)
def bring_to_front(self):
"""Bring selected element to front (end of list)"""
@ -53,7 +53,7 @@ class ZOrderOperationsMixin:
tab="Arrange",
group="Order",
shortcut="Ctrl+Shift+[",
requires_selection=True
requires_selection=True,
)
def send_to_back(self):
"""Send selected element to back (start of list)"""
@ -91,7 +91,7 @@ class ZOrderOperationsMixin:
tab="Arrange",
group="Order",
shortcut="Ctrl+]",
requires_selection=True
requires_selection=True,
)
def bring_forward(self):
"""Move selected element forward one position in list"""
@ -129,7 +129,7 @@ class ZOrderOperationsMixin:
tab="Arrange",
group="Order",
shortcut="Ctrl+[",
requires_selection=True
requires_selection=True,
)
def send_backward(self):
"""Move selected element backward one position in list"""
@ -168,7 +168,7 @@ class ZOrderOperationsMixin:
group="Order",
shortcut="Ctrl+Shift+X",
requires_selection=True,
min_selection=2
min_selection=2,
)
def swap_order(self):
"""Swap the z-order of two selected elements"""

View File

@ -33,11 +33,11 @@ class PageNavigationMixin:
Returns:
Tuple of (page, page_index, renderer) or (None, -1, None) if no page at coordinates
"""
if not hasattr(self, '_page_renderers') or not self._page_renderers:
if not hasattr(self, "_page_renderers") or not self._page_renderers:
return None, -1, None
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return None, -1, None
# Check each page to find which one contains the coordinates
@ -57,7 +57,7 @@ class PageNavigationMixin:
List of tuples (page_type, page_or_ghost_data, y_offset)
"""
main_window = self.window()
if not hasattr(main_window, 'project'):
if not hasattr(main_window, "project"):
return []
dpi = main_window.project.working_dpi
@ -77,7 +77,7 @@ class PageNavigationMixin:
# First, render cover if it exists
for page in main_window.project.pages:
if page.is_cover:
result.append(('page', page, current_y))
result.append(("page", page, current_y))
# Calculate cover height in pixels
page_height_mm = page.layout.size[1]
@ -91,7 +91,7 @@ class PageNavigationMixin:
layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts()
for page_type, page_obj, logical_pos in layout_with_ghosts:
if page_type == 'page':
if page_type == "page":
# Regular page (single or double spread)
result.append((page_type, page_obj, current_y))
@ -103,7 +103,7 @@ class PageNavigationMixin:
# Move to next position (add height + spacing)
current_y += page_height_px + spacing_px
elif page_type == 'ghost':
elif page_type == "ghost":
# Ghost page - use default page size
page_size_mm = main_window.project.page_size_mm
from pyPhotoAlbum.models import GhostPageData
@ -131,11 +131,11 @@ class PageNavigationMixin:
Returns:
bool: True if a ghost page was clicked and a new page was created
"""
if not hasattr(self, '_page_renderers'):
if not hasattr(self, "_page_renderers"):
return False
main_window = self.window()
if not hasattr(main_window, 'project'):
if not hasattr(main_window, "project"):
return False
# Get page positions which includes ghosts
@ -144,7 +144,7 @@ class PageNavigationMixin:
# Check each position for ghost pages
for idx, (page_type, page_or_ghost, y_offset) in enumerate(page_positions):
# Skip non-ghost pages
if page_type != 'ghost':
if page_type != "ghost":
continue
ghost = page_or_ghost
@ -156,20 +156,21 @@ class PageNavigationMixin:
screen_y = (y_offset * self.zoom_level) + self.pan_offset[1]
from pyPhotoAlbum.page_renderer import PageRenderer
renderer = PageRenderer(
page_width_mm=ghost_width_mm,
page_height_mm=ghost_height_mm,
screen_x=screen_x,
screen_y=screen_y,
dpi=dpi,
zoom=self.zoom_level
zoom=self.zoom_level,
)
# Check if click is anywhere on the ghost page (entire page is clickable)
if renderer.is_point_in_page(x, y):
# User clicked the ghost page!
# Calculate the insertion index (count real pages before this ghost in page_positions)
insert_index = sum(1 for i, (pt, _, _) in enumerate(page_positions) if i < idx and pt == 'page')
insert_index = sum(1 for i, (pt, _, _) in enumerate(page_positions) if i < idx and pt == "page")
print(f"Ghost page clicked at index {insert_index} - inserting new page in place")
@ -181,10 +182,9 @@ class PageNavigationMixin:
new_page_number = insert_index + 1
new_page = Page(
layout=PageLayout(
width=main_window.project.page_size_mm[0],
height=main_window.project.page_size_mm[1]
width=main_window.project.page_size_mm[0], height=main_window.project.page_size_mm[1]
),
page_number=new_page_number
page_number=new_page_number,
)
# Insert the page at the correct position
@ -209,10 +209,10 @@ class PageNavigationMixin:
y: Screen Y coordinate
"""
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return
if not hasattr(self, '_page_renderers') or not self._page_renderers:
if not hasattr(self, "_page_renderers") or not self._page_renderers:
return
# Get total page count (accounting for double spreads = 2 pages each)
@ -228,7 +228,7 @@ class PageNavigationMixin:
if page.is_double_spread:
side = renderer.get_sub_page_at(x, is_facing_page=True)
page_nums = page.get_page_numbers()
if side == 'left':
if side == "left":
current_page_info = f"Page {page_nums[0]}"
else:
current_page_info = f"Page {page_nums[1]}"
@ -237,8 +237,10 @@ class PageNavigationMixin:
break
# Update status bar
if hasattr(main_window, 'status_bar'):
if hasattr(main_window, "status_bar"):
if current_page_info:
main_window.status_bar.showMessage(f"{current_page_info} of {total_pages} | Zoom: {int(self.zoom_level * 100)}%")
main_window.status_bar.showMessage(
f"{current_page_info} of {total_pages} | Zoom: {int(self.zoom_level * 100)}%"
)
else:
main_window.status_bar.showMessage(f"Total pages: {total_pages} | Zoom: {int(self.zoom_level * 100)}%")

View File

@ -26,7 +26,7 @@ class RenderingMixin:
glLoadIdentity()
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return
# Set initial zoom and center the page if not done yet
@ -37,7 +37,7 @@ class RenderingMixin:
# Update scrollbars now that we have content bounds
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
dpi = main_window.project.working_dpi
@ -55,7 +55,7 @@ class RenderingMixin:
for page_info in page_positions:
page_type, page_or_ghost, y_offset = page_info
if page_type == 'page':
if page_type == "page":
page = page_or_ghost
page_width_mm, page_height_mm = page.layout.size
@ -68,7 +68,7 @@ class RenderingMixin:
screen_x=screen_x,
screen_y=screen_y,
dpi=dpi,
zoom=self.zoom_level
zoom=self.zoom_level,
)
self._page_renderers.append((renderer, page))
@ -79,7 +79,7 @@ class RenderingMixin:
page.layout.render(dpi=dpi, project=main_window.project)
renderer.end_render()
elif page_type == 'ghost':
elif page_type == "ghost":
ghost = page_or_ghost
ghost_width_mm, ghost_height_mm = ghost.page_size
@ -92,14 +92,14 @@ class RenderingMixin:
screen_x=screen_x,
screen_y=screen_y,
dpi=dpi,
zoom=self.zoom_level
zoom=self.zoom_level,
)
self._render_ghost_page(ghost, renderer)
# Update PageRenderer references for selected elements
for element in self.selected_elements:
if hasattr(element, '_parent_page'):
if hasattr(element, "_parent_page"):
for renderer, page in self._page_renderers:
if page is element._parent_page:
element._page_renderer = renderer
@ -118,10 +118,10 @@ class RenderingMixin:
return
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return
if not hasattr(element, '_page_renderer'):
if not hasattr(element, "_page_renderer"):
return
renderer = element._page_renderer
@ -188,10 +188,10 @@ class RenderingMixin:
glEnd()
else:
handles = [
(x - handle_size/2, y - handle_size/2),
(x + w - handle_size/2, y - handle_size/2),
(x - handle_size/2, y + h - handle_size/2),
(x + w - handle_size/2, y + h - handle_size/2),
(x - handle_size / 2, y - handle_size / 2),
(x + w - handle_size / 2, y - handle_size / 2),
(x - handle_size / 2, y + h - handle_size / 2),
(x + w - handle_size / 2, y + h - handle_size / 2),
]
glColor3f(1.0, 1.0, 1.0)
@ -214,7 +214,7 @@ class RenderingMixin:
def _render_text_overlays(self):
"""Render text content for TextBoxData elements using QPainter overlay"""
if not hasattr(self, '_page_renderers') or not self._page_renderers:
if not hasattr(self, "_page_renderers") or not self._page_renderers:
return
painter = QPainter(self)
@ -236,12 +236,12 @@ class RenderingMixin:
screen_w = w * renderer.zoom
screen_h = h * renderer.zoom
font_family = element.font_settings.get('family', 'Arial')
font_size = int(element.font_settings.get('size', 12) * renderer.zoom)
font_family = element.font_settings.get("family", "Arial")
font_size = int(element.font_settings.get("size", 12) * renderer.zoom)
font = QFont(font_family, font_size)
painter.setFont(font)
font_color = element.font_settings.get('color', (0, 0, 0))
font_color = element.font_settings.get("color", (0, 0, 0))
if all(isinstance(c, int) and c > 1 for c in font_color):
color = QColor(*font_color)
else:
@ -260,9 +260,9 @@ class RenderingMixin:
rect = QRectF(screen_x, screen_y, screen_w, screen_h)
alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
if element.alignment == 'center':
if element.alignment == "center":
alignment = Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop
elif element.alignment == 'right':
elif element.alignment == "right":
alignment = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop
text_flags = Qt.TextFlag.TextWordWrap

View File

@ -61,7 +61,7 @@ class ViewportMixin:
# Update scrollbars when viewport size changes
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
def _calculate_fit_to_screen_zoom(self):
@ -72,7 +72,7 @@ class ViewportMixin:
float: Zoom level (1.0 = 100%, 0.5 = 50%, etc.)
"""
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return 1.0
window_width = self.width()
@ -106,7 +106,7 @@ class ViewportMixin:
list: [x_offset, y_offset] to center the page
"""
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return [0, 0]
window_width = self.width()
@ -141,8 +141,8 @@ class ViewportMixin:
dict: {'min_x', 'max_x', 'min_y', 'max_y', 'width', 'height'} in pixels
"""
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
return {'min_x': 0, 'max_x': 800, 'min_y': 0, 'max_y': 600, 'width': 800, 'height': 600}
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return {"min_x": 0, "max_x": 800, "min_y": 0, "max_y": 600, "width": 800, "height": 600}
dpi = main_window.project.working_dpi
PAGE_MARGIN = 50
@ -167,12 +167,12 @@ class ViewportMixin:
total_height += PAGE_MARGIN
return {
'min_x': 0,
'max_x': total_width,
'min_y': 0,
'max_y': total_height,
'width': total_width,
'height': total_height
"min_x": 0,
"max_x": total_width,
"min_y": 0,
"max_y": total_height,
"width": total_width,
"height": total_height,
}
def clamp_pan_offset(self):
@ -188,7 +188,7 @@ class ViewportMixin:
when zooming on pages of different widths.
"""
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return
viewport_width = self.width()
@ -200,7 +200,7 @@ class ViewportMixin:
# Vertical clamping
bounds = self.get_content_bounds()
content_height = bounds['height']
content_height = bounds["height"]
# Save original pan_offset[1] BEFORE clamping for page selection
# This prevents clamping from changing which page we think we're on

View File

@ -13,13 +13,39 @@ from PIL import Image
from pyPhotoAlbum.image_utils import apply_pil_rotation, calculate_center_crop_coords
from pyPhotoAlbum.gl_imports import (
GL_AVAILABLE, glBegin, glEnd, glVertex2f, glColor3f, glColor4f,
GL_QUADS, GL_LINE_LOOP, glEnable, glDisable, GL_TEXTURE_2D,
glBindTexture, glTexCoord2f, glTexParameteri, GL_TEXTURE_MIN_FILTER,
GL_TEXTURE_MAG_FILTER, GL_LINEAR, glGenTextures, glTexImage2D,
GL_RGBA, GL_UNSIGNED_BYTE, glDeleteTextures, glGetString, GL_VERSION,
glLineStipple, GL_LINE_STIPPLE, glPushMatrix, glPopMatrix,
glTranslatef, glRotatef, GL_BLEND, glBlendFunc, GL_SRC_ALPHA,
GL_AVAILABLE,
glBegin,
glEnd,
glVertex2f,
glColor3f,
glColor4f,
GL_QUADS,
GL_LINE_LOOP,
glEnable,
glDisable,
GL_TEXTURE_2D,
glBindTexture,
glTexCoord2f,
glTexParameteri,
GL_TEXTURE_MIN_FILTER,
GL_TEXTURE_MAG_FILTER,
GL_LINEAR,
glGenTextures,
glTexImage2D,
GL_RGBA,
GL_UNSIGNED_BYTE,
glDeleteTextures,
glGetString,
GL_VERSION,
glLineStipple,
GL_LINE_STIPPLE,
glPushMatrix,
glPopMatrix,
glTranslatef,
glRotatef,
GL_BLEND,
glBlendFunc,
GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA,
)
@ -48,10 +74,13 @@ def get_asset_search_paths() -> Tuple[Optional[str], List[str]]:
"""Get the current asset resolution context."""
return _primary_project_folder, _asset_search_paths
class BaseLayoutElement(ABC):
"""Abstract base class for all layout elements"""
def __init__(self, x: float = 0, y: float = 0, width: float = 100, height: float = 100, rotation: float = 0, z_index: int = 0):
def __init__(
self, x: float = 0, y: float = 0, width: float = 100, height: float = 100, rotation: float = 0, z_index: int = 0
):
self.position = (x, y)
self.size = (width, height)
self.rotation = rotation
@ -118,11 +147,17 @@ class BaseLayoutElement(ABC):
"""Deserialize from a dictionary"""
pass
class ImageData(BaseLayoutElement):
"""Class to store image data and properties"""
def __init__(self, image_path: str = "", crop_info: Optional[Tuple] = None,
image_dimensions: Optional[Tuple[int, int]] = None, **kwargs):
def __init__(
self,
image_path: str = "",
crop_info: Optional[Tuple] = None,
image_dimensions: Optional[Tuple[int, int]] = None,
**kwargs,
):
super().__init__(**kwargs)
self.image_path = image_path
self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop
@ -190,15 +225,15 @@ class ImageData(BaseLayoutElement):
# Create texture from pending image if one exists (deferred from async load)
# Texture creation must happen during render when GL context is active
if hasattr(self, '_pending_pil_image') and self._pending_pil_image is not None:
if hasattr(self, "_pending_pil_image") and self._pending_pil_image is not None:
self._create_texture_from_pending_image()
# Use cached texture if available
if hasattr(self, '_texture_id') and self._texture_id:
if hasattr(self, "_texture_id") and self._texture_id:
texture_id = self._texture_id
# Get image dimensions (from loaded texture or metadata)
if hasattr(self, '_img_width') and hasattr(self, '_img_height'):
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
@ -207,9 +242,7 @@ class ImageData(BaseLayoutElement):
img_width, img_height = int(w), int(h)
# Calculate texture coordinates for center crop with element's crop_info
tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords(
img_width, img_height, w, h, self.crop_info
)
tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords(img_width, img_height, w, h, self.crop_info)
# Enable texturing and draw with crop
glEnable(GL_TEXTURE_2D)
@ -217,10 +250,14 @@ class ImageData(BaseLayoutElement):
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)
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)
@ -254,7 +291,7 @@ class ImageData(BaseLayoutElement):
"z_index": self.z_index,
"image_path": self.image_path,
"crop_info": self.crop_info,
"pil_rotation_90": getattr(self, 'pil_rotation_90', 0)
"pil_rotation_90": getattr(self, "pil_rotation_90", 0),
}
# Include image dimensions metadata if available
if self.image_dimensions:
@ -313,7 +350,7 @@ class ImageData(BaseLayoutElement):
logger.debug(f"ImageData: Async load completed for {self.image_path}, size: {pil_image.size}")
# Apply PIL-level rotation if needed
if hasattr(self, 'pil_rotation_90') and self.pil_rotation_90 > 0:
if hasattr(self, "pil_rotation_90") and self.pil_rotation_90 > 0:
pil_image = apply_pil_rotation(pil_image, self.pil_rotation_90)
logger.debug(f"ImageData: Applied PIL rotation {self.pil_rotation_90 * 90}° to {self.image_path}")
@ -339,7 +376,7 @@ class ImageData(BaseLayoutElement):
Create OpenGL texture from pending PIL image.
Called during render() when GL context is active.
"""
if not hasattr(self, '_pending_pil_image') or self._pending_pil_image is None:
if not hasattr(self, "_pending_pil_image") or self._pending_pil_image is None:
return False
try:
@ -355,11 +392,11 @@ class ImageData(BaseLayoutElement):
pil_image = self._pending_pil_image
# Ensure RGBA format for GL_RGBA texture (defensive check)
if pil_image.mode != 'RGBA':
pil_image = pil_image.convert('RGBA')
if pil_image.mode != "RGBA":
pil_image = pil_image.convert("RGBA")
# Delete old texture if it exists
if hasattr(self, '_texture_id') and self._texture_id:
if hasattr(self, "_texture_id") and self._texture_id:
glDeleteTextures([self._texture_id])
# Create GPU texture from pre-processed PIL image
@ -369,8 +406,9 @@ class ImageData(BaseLayoutElement):
glBindTexture(GL_TEXTURE_2D, texture_id)
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, pil_image.width, pil_image.height,
0, GL_RGBA, GL_UNSIGNED_BYTE, img_data)
glTexImage2D(
GL_TEXTURE_2D, 0, GL_RGBA, pil_image.width, pil_image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data
)
# Cache texture
self._texture_id = texture_id
@ -380,8 +418,8 @@ class ImageData(BaseLayoutElement):
self._pending_pil_image = None
# Clear the warning flag if we successfully created the texture
if hasattr(self, '_gl_context_warned'):
delattr(self, '_gl_context_warned')
if hasattr(self, "_gl_context_warned"):
delattr(self, "_gl_context_warned")
logger.info(f"ImageData: Successfully created texture for {self.image_path}")
return True
@ -390,11 +428,13 @@ class ImageData(BaseLayoutElement):
error_str = str(e)
# Check if this is a GL context error (err 1282 = GL_INVALID_OPERATION)
# These are typically caused by no GL context being current
if 'GLError' in error_str and '1282' in error_str:
if "GLError" in error_str and "1282" in error_str:
# GL context not ready - keep pending image and try again next render
# Don't spam the console with repeated messages
if not hasattr(self, '_gl_context_warned'):
logger.warning(f"ImageData: GL context error (1282) for {self.image_path}, will retry on next render")
if not hasattr(self, "_gl_context_warned"):
logger.warning(
f"ImageData: GL context error (1282) for {self.image_path}, will retry on next render"
)
self._gl_context_warned = True
return False
else:
@ -415,6 +455,7 @@ class ImageData(BaseLayoutElement):
self._async_loading = False
self._async_load_requested = False
class PlaceholderData(BaseLayoutElement):
"""Class to store placeholder data"""
@ -475,7 +516,7 @@ class PlaceholderData(BaseLayoutElement):
"rotation": self.rotation,
"z_index": self.z_index,
"placeholder_type": self.placeholder_type,
"default_content": self.default_content
"default_content": self.default_content,
}
# Add base fields (v3.0+)
data.update(self._serialize_base_fields())
@ -493,6 +534,7 @@ class PlaceholderData(BaseLayoutElement):
self.placeholder_type = data.get("placeholder_type", "image")
self.default_content = data.get("default_content", "")
class TextBoxData(BaseLayoutElement):
"""Class to store text box data"""
@ -559,7 +601,7 @@ class TextBoxData(BaseLayoutElement):
"z_index": self.z_index,
"text_content": self.text_content,
"font_settings": self.font_settings,
"alignment": self.alignment
"alignment": self.alignment,
}
# Add base fields (v3.0+)
data.update(self._serialize_base_fields())
@ -578,6 +620,7 @@ class TextBoxData(BaseLayoutElement):
self.font_settings = data.get("font_settings", {"family": "Arial", "size": 12, "color": (0, 0, 0)})
self.alignment = data.get("alignment", "left")
class GhostPageData(BaseLayoutElement):
"""Class to represent a ghost page placeholder for alignment in double-page spreads"""
@ -640,12 +683,7 @@ class GhostPageData(BaseLayoutElement):
def serialize(self) -> Dict[str, Any]:
"""Serialize ghost page data to dictionary"""
data = {
"type": "ghostpage",
"position": self.position,
"size": self.size,
"page_size": self.page_size
}
data = {"type": "ghostpage", "position": self.position, "size": self.size, "page_size": self.page_size}
# Add base fields (v3.0+)
data.update(self._serialize_base_fields())
return data

View File

@ -6,12 +6,25 @@ from typing import List, Dict, Any, Optional, Tuple
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
from pyPhotoAlbum.snapping import SnappingSystem
from pyPhotoAlbum.gl_imports import (
glBegin, glEnd, glVertex2f, glColor3f, glColor4f,
GL_QUADS, GL_LINE_LOOP, GL_LINES, glLineWidth,
glEnable, glDisable, GL_DEPTH_TEST, GL_BLEND,
glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,
glBegin,
glEnd,
glVertex2f,
glColor3f,
glColor4f,
GL_QUADS,
GL_LINE_LOOP,
GL_LINES,
glLineWidth,
glEnable,
glDisable,
GL_DEPTH_TEST,
GL_BLEND,
glBlendFunc,
GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA,
)
class PageLayout:
"""Class to manage page layout and templates"""
@ -41,7 +54,7 @@ class PageLayout:
"""Remove a layout element from the page"""
self.elements.remove(element)
def set_grid_layout(self, grid: 'GridLayout'):
def set_grid_layout(self, grid: "GridLayout"):
"""Set a grid layout for the page"""
self.grid_layout = grid
@ -97,11 +110,11 @@ class PageLayout:
# For ImageData elements, request async loading if available
for element in self.elements:
# Check if this is an ImageData element that needs async loading
if isinstance(element, ImageData) and not hasattr(element, '_texture_id'):
if isinstance(element, ImageData) and not hasattr(element, "_texture_id"):
# Try to get async loader from a parent widget
if hasattr(self, '_async_loader'):
if hasattr(self, "_async_loader"):
loader = self._async_loader
elif hasattr(self, '_parent_widget') and hasattr(self._parent_widget, 'async_image_loader'):
elif hasattr(self, "_parent_widget") and hasattr(self._parent_widget, "async_image_loader"):
loader = self._parent_widget.async_image_loader
else:
loader = None
@ -109,8 +122,9 @@ class PageLayout:
# Request async load if loader is available and not already requested
if loader and not element._async_load_requested:
from pyPhotoAlbum.async_backend import LoadPriority
# Determine priority based on visibility (HIGH for now, can be refined)
if hasattr(self._parent_widget, 'request_image_load'):
if hasattr(self._parent_widget, "request_image_load"):
self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH)
element._async_load_requested = True
element._async_loading = True
@ -169,6 +183,7 @@ class PageLayout:
# Create a temporary snapping system with project settings to get snap lines
from pyPhotoAlbum.snapping import SnappingSystem
temp_snap_sys = SnappingSystem(snap_threshold_mm=snap_threshold_mm)
temp_snap_sys.grid_size_mm = grid_size_mm
temp_snap_sys.snap_to_grid = snap_to_grid
@ -183,12 +198,12 @@ class PageLayout:
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Draw grid lines (darker gray with transparency) - visible when show_grid is enabled
if show_grid and snap_lines['grid']:
if show_grid and snap_lines["grid"]:
glColor4f(0.6, 0.6, 0.6, 0.4) # Gray with 40% opacity
glLineWidth(1.0)
for orientation, position in snap_lines['grid']:
for orientation, position in snap_lines["grid"]:
glBegin(GL_LINES)
if orientation == 'vertical':
if orientation == "vertical":
glVertex2f(page_x + position, page_y)
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
else: # horizontal
@ -197,12 +212,12 @@ class PageLayout:
glEnd()
# Draw guides (cyan, more visible with transparency) - only show when show_snap_lines is on
if show_snap_lines and snap_lines['guides']:
if show_snap_lines and snap_lines["guides"]:
glColor4f(0.0, 0.7, 0.9, 0.8) # Cyan with 80% opacity
glLineWidth(1.5)
for orientation, position in snap_lines['guides']:
for orientation, position in snap_lines["guides"]:
glBegin(GL_LINES)
if orientation == 'vertical':
if orientation == "vertical":
glVertex2f(page_x + position, page_y)
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
else: # horizontal
@ -223,7 +238,7 @@ class PageLayout:
"elements": [elem.serialize() for elem in self.elements],
"grid_layout": self.grid_layout.serialize() if self.grid_layout else None,
"snapping_system": self.snapping_system.serialize(),
"show_snap_lines": self.show_snap_lines
"show_snap_lines": self.show_snap_lines,
}
def deserialize(self, data: Dict[str, Any]):
@ -268,6 +283,7 @@ class PageLayout:
self.show_snap_lines = data.get("show_snap_lines", True)
class GridLayout:
"""Class to manage grid layouts"""
@ -281,7 +297,9 @@ class GridLayout:
"""Merge cells in the grid"""
self.merged_cells.append((row, col))
def get_cell_position(self, row: int, col: int, page_width: float = 800, page_height: float = 600) -> Tuple[float, float]:
def get_cell_position(
self, row: int, col: int, page_width: float = 800, page_height: float = 600
) -> Tuple[float, float]:
"""Get the position of a grid cell"""
cell_width = (page_width - (self.spacing * (self.columns + 1))) / self.columns
cell_height = (page_height - (self.spacing * (self.rows + 1))) / self.rows
@ -300,12 +318,7 @@ class GridLayout:
def serialize(self) -> Dict[str, Any]:
"""Serialize grid layout to dictionary"""
return {
"rows": self.rows,
"columns": self.columns,
"spacing": self.spacing,
"merged_cells": self.merged_cells
}
return {"rows": self.rows, "columns": self.columns, "spacing": self.spacing, "merged_cells": self.merged_cells}
def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary"""

View File

@ -22,13 +22,9 @@ class PageRenderer:
a page and its elements consistently.
"""
def __init__(self,
page_width_mm: float,
page_height_mm: float,
screen_x: float,
screen_y: float,
dpi: int,
zoom: float):
def __init__(
self, page_width_mm: float, page_height_mm: float, screen_x: float, screen_y: float, dpi: int, zoom: float
):
"""
Initialize a page renderer.
@ -96,8 +92,10 @@ class PageRenderer:
Returns:
True if the point is within the page bounds
"""
return (self.screen_x <= screen_x <= self.screen_x + self.screen_width and
self.screen_y <= screen_y <= self.screen_y + self.screen_height)
return (
self.screen_x <= screen_x <= self.screen_x + self.screen_width
and self.screen_y <= screen_y <= self.screen_y + self.screen_height
)
def get_sub_page_at(self, screen_x: float, is_facing_page: bool) -> Optional[str]:
"""
@ -117,9 +115,9 @@ class PageRenderer:
center_x = self.screen_x + self.screen_width / 2
if screen_x < center_x:
return 'left'
return "left"
else:
return 'right'
return "right"
def begin_render(self):
"""

View File

@ -25,6 +25,7 @@ from pyPhotoAlbum.image_utils import (
@dataclass
class RenderContext:
"""Parameters for rendering an image element"""
canvas: canvas.Canvas
image_element: ImageData
x_pt: float
@ -41,6 +42,7 @@ class RenderContext:
@dataclass
class SplitRenderParams:
"""Parameters for rendering a split element"""
canvas: canvas.Canvas
element: any
x_offset_mm: float
@ -89,8 +91,7 @@ class PDFExporter:
try:
# Calculate total pages for progress (cover counts as 1)
total_pages = sum(
1 if page.is_cover else (2 if page.is_double_spread else 1)
for page in self.project.pages
1 if page.is_cover else (2 if page.is_double_spread else 1) for page in self.project.pages
)
# Get page dimensions from project (in mm)
@ -110,8 +111,7 @@ class PDFExporter:
page_name = self.project.get_page_display_name(page)
if progress_callback:
progress_callback(pages_processed, total_pages,
f"Exporting {page_name}...")
progress_callback(pages_processed, total_pages, f"Exporting {page_name}...")
if page.is_cover:
# Export cover as single page with wrap-around design
@ -124,8 +124,7 @@ class PDFExporter:
c.showPage() # Finish current page
self.current_pdf_page += 1
if progress_callback:
progress_callback(pages_processed, total_pages,
f"Inserting blank page for alignment...")
progress_callback(pages_processed, total_pages, f"Inserting blank page for alignment...")
# Export spread as two pages
self._export_spread(c, page, page_width_pt, page_height_pt)
@ -147,8 +146,7 @@ class PDFExporter:
self.warnings.append(f"Export failed: {str(e)}")
return False, self.warnings
def _export_cover(self, c: canvas.Canvas, page, page_width_pt: float,
page_height_pt: float):
def _export_cover(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float):
"""
Export a cover page to PDF.
Cover has different dimensions (wrap-around: front + spine + back + bleed).
@ -211,8 +209,7 @@ class PDFExporter:
c.restoreState()
def _export_single_page(self, c: canvas.Canvas, page, page_width_pt: float,
page_height_pt: float):
def _export_single_page(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float):
"""Export a single page to PDF"""
# Render all elements
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
@ -221,8 +218,7 @@ class PDFExporter:
c.showPage() # Finish this page
self.current_pdf_page += 1
def _export_spread(self, c: canvas.Canvas, page, page_width_pt: float,
page_height_pt: float):
def _export_spread(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float):
"""Export a double-page spread as two PDF pages"""
# Get center line position in mm
page_width_mm = self.project.page_size_mm[0]
@ -257,7 +253,7 @@ class PDFExporter:
page_width_pt=page_width_pt,
page_height_pt=page_height_pt,
page_number=page.page_number,
side='left'
side="left",
)
self._render_split_element(params)
@ -272,8 +268,7 @@ class PDFExporter:
# Check if element is on right page or spanning (compare in pixels)
if element_x_px >= center_px - threshold_px and element_x_px + element_width_px > center_px:
# Entirely on right page or mostly on right
self._render_element(c, element, center_mm, page_width_pt, page_height_pt,
page.page_number + 1)
self._render_element(c, element, center_mm, page_width_pt, page_height_pt, page.page_number + 1)
elif element_x_px < center_px and element_x_px + element_width_px > center_px + threshold_px:
# Spanning element - render right portion
params = SplitRenderParams(
@ -284,15 +279,22 @@ class PDFExporter:
page_width_pt=page_width_pt,
page_height_pt=page_height_pt,
page_number=page.page_number + 1,
side='right'
side="right",
)
self._render_split_element(params)
c.showPage() # Finish right page
self.current_pdf_page += 1
def _render_element(self, c: canvas.Canvas, element, x_offset_mm: float,
page_width_pt: float, page_height_pt: float, page_number: int):
def _render_element(
self,
c: canvas.Canvas,
element,
x_offset_mm: float,
page_width_pt: float,
page_height_pt: float,
page_number: int,
):
"""
Render a single element on the PDF canvas.
@ -336,7 +338,7 @@ class PDFExporter:
y_pt=y_pt,
width_pt=width_pt,
height_pt=height_pt,
page_number=page_number
page_number=page_number,
)
self._render_image(ctx)
elif isinstance(element, TextBoxData):
@ -366,7 +368,7 @@ class PDFExporter:
if isinstance(params.element, ImageData):
# Calculate which portion of the image to render
if params.side == 'left':
if params.side == "left":
# Render from element start to split line
crop_width_mm = params.split_line_mm - element_x_mm
crop_x_start = 0
@ -402,7 +404,7 @@ class PDFExporter:
crop_left=crop_x_start / element_width_mm,
crop_right=(crop_x_start + crop_width_mm) / element_width_mm,
original_width_pt=original_width_pt,
original_height_pt=original_height_pt
original_height_pt=original_height_pt,
)
self._render_image(ctx)
@ -410,10 +412,17 @@ class PDFExporter:
# For text boxes spanning the split, we'll render the whole text on the side
# where most of it appears (simpler than trying to split text)
element_center_mm = element_x_mm + element_width_mm / 2
if (params.side == 'left' and element_center_mm < params.split_line_mm) or \
(params.side == 'right' and element_center_mm >= params.split_line_mm):
self._render_element(params.canvas, params.element, params.x_offset_mm,
params.page_width_pt, params.page_height_pt, params.page_number)
if (params.side == "left" and element_center_mm < params.split_line_mm) or (
params.side == "right" and element_center_mm >= params.split_line_mm
):
self._render_element(
params.canvas,
params.element,
params.x_offset_mm,
params.page_width_pt,
params.page_height_pt,
params.page_number,
)
def _render_image(self, ctx: RenderContext):
"""
@ -438,7 +447,7 @@ class PDFExporter:
img = convert_to_rgba(img)
# Apply PIL-level rotation if needed
if hasattr(ctx.image_element, 'pil_rotation_90') and ctx.image_element.pil_rotation_90 > 0:
if hasattr(ctx.image_element, "pil_rotation_90") and ctx.image_element.pil_rotation_90 > 0:
img = apply_pil_rotation(img, ctx.image_element.pil_rotation_90)
# Get element's crop_info and combine with split cropping if applicable
@ -458,9 +467,11 @@ class PDFExporter:
# Calculate center crop coordinates
img_width, img_height = img.size
crop_coords = calculate_center_crop_coords(
img_width, img_height,
target_width, target_height,
(final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max)
img_width,
img_height,
target_width,
target_height,
(final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max),
)
# Crop the image
@ -477,8 +488,7 @@ 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),
Image.Resampling.LANCZOS)
cropped_img = cropped_img.resize((target_width_px, target_height_px), Image.Resampling.LANCZOS)
# Note: Rotation is applied at the canvas level (below), not here
# to avoid double-rotation issues
@ -495,12 +505,20 @@ class PDFExporter:
ctx.canvas.rotate(-ctx.image_element.rotation)
ctx.canvas.translate(-ctx.width_pt / 2, -ctx.height_pt / 2)
# Draw at origin after transformation
ctx.canvas.drawImage(ImageReader(cropped_img), 0, 0, ctx.width_pt, ctx.height_pt,
mask='auto', preserveAspectRatio=False)
ctx.canvas.drawImage(
ImageReader(cropped_img), 0, 0, ctx.width_pt, ctx.height_pt, mask="auto", preserveAspectRatio=False
)
else:
# Draw without rotation
ctx.canvas.drawImage(ImageReader(cropped_img), ctx.x_pt, ctx.y_pt, ctx.width_pt, ctx.height_pt,
mask='auto', preserveAspectRatio=False)
ctx.canvas.drawImage(
ImageReader(cropped_img),
ctx.x_pt,
ctx.y_pt,
ctx.width_pt,
ctx.height_pt,
mask="auto",
preserveAspectRatio=False,
)
ctx.canvas.restoreState()
@ -509,8 +527,9 @@ class PDFExporter:
print(f"WARNING: {warning}")
self.warnings.append(warning)
def _render_textbox(self, c: canvas.Canvas, text_element: 'TextBoxData',
x_pt: float, y_pt: float, width_pt: float, height_pt: float):
def _render_textbox(
self, c: canvas.Canvas, text_element: "TextBoxData", x_pt: float, y_pt: float, width_pt: float, height_pt: float
):
"""
Render a text box element on the PDF canvas with transparent background.
Text is word-wrapped to fit within the box boundaries.
@ -524,9 +543,9 @@ class PDFExporter:
return
# Get font settings
font_family = text_element.font_settings.get('family', 'Helvetica')
font_size_px = text_element.font_settings.get('size', 12)
font_color = text_element.font_settings.get('color', (0, 0, 0))
font_family = text_element.font_settings.get("family", "Helvetica")
font_size_px = text_element.font_settings.get("size", 12)
font_color = text_element.font_settings.get("color", (0, 0, 0))
# Convert font size from pixels to PDF points (same conversion as element dimensions)
# Font size is stored in pixels at working_dpi, same as element position/size
@ -536,34 +555,32 @@ class PDFExporter:
# Map common font names to ReportLab standard fonts
font_map = {
'Arial': 'Helvetica',
'Times New Roman': 'Times-Roman',
'Courier New': 'Courier',
"Arial": "Helvetica",
"Times New Roman": "Times-Roman",
"Courier New": "Courier",
}
font_family = font_map.get(font_family, font_family)
# Normalize color to hex for Paragraph style
if all(isinstance(x, int) and x > 1 for x in font_color):
color_hex = '#{:02x}{:02x}{:02x}'.format(*font_color)
color_hex = "#{:02x}{:02x}{:02x}".format(*font_color)
else:
# Convert 0-1 range to 0-255 then to hex
color_hex = '#{:02x}{:02x}{:02x}'.format(
int(font_color[0] * 255),
int(font_color[1] * 255),
int(font_color[2] * 255)
color_hex = "#{:02x}{:02x}{:02x}".format(
int(font_color[0] * 255), int(font_color[1] * 255), int(font_color[2] * 255)
)
# Map alignment to ReportLab constants
alignment_map = {
'left': TA_LEFT,
'center': TA_CENTER,
'right': TA_RIGHT,
"left": TA_LEFT,
"center": TA_CENTER,
"right": TA_RIGHT,
}
text_alignment = alignment_map.get(text_element.alignment, TA_LEFT)
# Create paragraph style with word wrapping
style = ParagraphStyle(
'textbox',
"textbox",
fontName=font_family,
fontSize=font_size,
leading=font_size * 1.2, # Line spacing (120% of font size)
@ -573,10 +590,10 @@ class PDFExporter:
# Escape special XML characters and convert newlines to <br/> tags
text_content = text_element.text_content
text_content = text_content.replace('&', '&amp;')
text_content = text_content.replace('<', '&lt;')
text_content = text_content.replace('>', '&gt;')
text_content = text_content.replace('\n', '<br/>')
text_content = text_content.replace("&", "&amp;")
text_content = text_content.replace("<", "&lt;")
text_content = text_content.replace(">", "&gt;")
text_content = text_content.replace("\n", "<br/>")
# Create paragraph with the text
para = Paragraph(text_content, style)

View File

@ -11,6 +11,7 @@ from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.commands import CommandHistory
from pyPhotoAlbum.asset_manager import AssetManager
class Page:
"""Class representing a single page in the photo album"""
@ -51,7 +52,7 @@ class Page:
self.layout.is_facing_page = is_double_spread
height = self.layout.size[1]
# Use the base_width if available, otherwise derive it
if hasattr(self.layout, 'base_width'):
if hasattr(self.layout, "base_width"):
base_width = self.layout.base_width
else:
# If base_width not set, assume current width is correct
@ -133,6 +134,7 @@ class Page:
self.layout = PageLayout()
self.layout.deserialize(layout_data)
class Project:
"""Class representing the entire photo album project"""
@ -246,11 +248,7 @@ class Project:
return 0.0
# Count content pages (excluding cover)
content_page_count = sum(
page.get_page_count()
for page in self.pages
if not page.is_cover
)
content_page_count = sum(page.get_page_count() for page in self.pages if not page.is_cover)
if self.binding_type == "saddle_stitch":
# Calculate number of sheets (each sheet = 4 pages)
@ -295,9 +293,11 @@ class Project:
cover_page.layout.base_width = page_width_mm # Store base width for reference
cover_page.manually_sized = True # Mark as manually sized
print(f"Cover dimensions updated: {cover_width:.1f} × {cover_height:.1f} mm "
f"(Front: {page_width_mm}, Spine: {spine_width:.2f}, Back: {page_width_mm}, "
f"Bleed: {self.cover_bleed_mm})")
print(
f"Cover dimensions updated: {cover_width:.1f} × {cover_height:.1f} mm "
f"(Front: {page_width_mm}, Spine: {spine_width:.2f}, Back: {page_width_mm}, "
f"Bleed: {self.cover_bleed_mm})"
)
def get_page_display_name(self, page: Page) -> str:
"""
@ -368,11 +368,11 @@ class Project:
# Check if this is a double spread starting at an odd position
if page.is_double_spread and current_position % 2 == 1:
# Need to insert a ghost page to push the double spread to next position
layout.append(('ghost', None, current_position))
layout.append(("ghost", None, current_position))
current_position += 1
# Add the actual page
layout.append(('page', page, current_position))
layout.append(("page", page, current_position))
# Update position based on page type
if page.is_double_spread:

View File

@ -14,7 +14,7 @@ from pyPhotoAlbum.version_manager import (
CURRENT_DATA_VERSION,
check_version_compatibility,
VersionCompatibility,
DataMigration
DataMigration,
)
@ -44,7 +44,7 @@ def _import_external_images(project: Project):
# Absolute path - definitely external
is_external = True
external_path = element.image_path
elif not element.image_path.startswith('assets/'):
elif not element.image_path.startswith("assets/"):
# Relative path but not in assets folder
# Check if it exists relative to project folder
full_path = os.path.join(project.folder_path, element.image_path)
@ -91,15 +91,15 @@ def _normalize_asset_paths(project: Project, project_folder: str):
original_path = element.image_path
# Skip if already a simple relative path (assets/...)
if not os.path.isabs(original_path) and not original_path.startswith('./projects/'):
if not os.path.isabs(original_path) and not original_path.startswith("./projects/"):
continue
# Try to extract just the filename or relative path from assets folder
# Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
if '/assets/' in original_path:
parts = original_path.split('/assets/')
if "/assets/" in original_path:
parts = original_path.split("/assets/")
if len(parts) == 2:
new_path = os.path.join('assets', parts[1])
new_path = os.path.join("assets", parts[1])
element.image_path = new_path
normalized_count += 1
print(f"Normalized path: {original_path} -> {new_path}")
@ -133,8 +133,8 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
"""
try:
# Ensure .ppz extension
if not zip_path.lower().endswith('.ppz'):
zip_path += '.ppz'
if not zip_path.lower().endswith(".ppz"):
zip_path += ".ppz"
# Check for and import any external images before saving
_import_external_images(project)
@ -143,14 +143,14 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
project_data = project.serialize()
# Add version information
project_data['serialization_version'] = SERIALIZATION_VERSION # Legacy field
project_data['data_version'] = CURRENT_DATA_VERSION # New versioning system
project_data["serialization_version"] = SERIALIZATION_VERSION # Legacy field
project_data["data_version"] = CURRENT_DATA_VERSION # New versioning system
# Create ZIP file
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
# Write project.json with stable sorting for git-friendly diffs
project_json = json.dumps(project_data, indent=2, sort_keys=True)
zipf.writestr('project.json', project_json)
zipf.writestr("project.json", project_json)
# Add all files from the assets folder
assets_folder = project.asset_manager.assets_folder
@ -201,20 +201,20 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
os.makedirs(extract_to, exist_ok=True)
# Extract ZIP contents
with zipfile.ZipFile(zip_path, 'r') as zipf:
with zipfile.ZipFile(zip_path, "r") as zipf:
zipf.extractall(extract_to)
# Load project.json
project_json_path = os.path.join(extract_to, 'project.json')
project_json_path = os.path.join(extract_to, "project.json")
if not os.path.exists(project_json_path):
raise ValueError("Invalid project file: project.json not found")
with open(project_json_path, 'r') as f:
with open(project_json_path, "r") as f:
project_data = json.load(f)
# Check version compatibility
# Try new version field first, fall back to legacy field
file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0'))
file_version = project_data.get("data_version", project_data.get("serialization_version", "1.0"))
# Check if version is compatible
is_compatible, error_msg = check_version_compatibility(file_version, zip_path)
@ -230,7 +230,7 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
print(f"Note: Loading project with version {file_version}, current version is {CURRENT_DATA_VERSION}")
# Create new project
project_name = project_data.get('name', 'Untitled Project')
project_name = project_data.get("name", "Untitled Project")
project = Project(name=project_name, folder_path=extract_to)
# Deserialize project data
@ -254,6 +254,7 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
# Set asset resolution context for ImageData rendering
# Only set project folder - search paths are reserved for healing functionality
from pyPhotoAlbum.models import set_asset_resolution_context
set_asset_resolution_context(extract_to)
print(f"Project loaded from {zip_path} to {extract_to}")
@ -271,17 +272,17 @@ def get_project_info(zip_path: str) -> Optional[dict]:
Dictionary with project info, or None if error
"""
try:
with zipfile.ZipFile(zip_path, 'r') as zipf:
with zipfile.ZipFile(zip_path, "r") as zipf:
# Read project.json
project_json = zipf.read('project.json').decode('utf-8')
project_json = zipf.read("project.json").decode("utf-8")
project_data = json.loads(project_json)
return {
'name': project_data.get('name', 'Unknown'),
'version': project_data.get('serialization_version', 'Unknown'),
'page_count': len(project_data.get('pages', [])),
'page_size_mm': project_data.get('page_size_mm', (0, 0)),
'working_dpi': project_data.get('working_dpi', 300),
"name": project_data.get("name", "Unknown"),
"version": project_data.get("serialization_version", "Unknown"),
"page_count": len(project_data.get("pages", [])),
"page_size_mm": project_data.get("page_size_mm", (0, 0)),
"working_dpi": project_data.get("working_dpi", 300),
}
except Exception as e:
print(f"Error reading project info: {e}")

View File

@ -49,21 +49,23 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
attr = getattr(window_class, attr_name)
# Check if this attribute has ribbon action metadata
if hasattr(attr, '_ribbon_action'):
if hasattr(attr, "_ribbon_action"):
action_data = attr._ribbon_action
# Extract tab and group information
tab_name = action_data['tab']
group_name = action_data['group']
tab_name = action_data["tab"]
group_name = action_data["group"]
# Add action to the appropriate tab and group
tabs[tab_name][group_name].append({
'label': action_data['label'],
'action': action_data['action'],
'tooltip': action_data['tooltip'],
'icon': action_data.get('icon'),
'shortcut': action_data.get('shortcut'),
})
tabs[tab_name][group_name].append(
{
"label": action_data["label"],
"action": action_data["action"],
"tooltip": action_data["tooltip"],
"icon": action_data.get("icon"),
"shortcut": action_data.get("shortcut"),
}
)
except (AttributeError, TypeError):
# Skip attributes that can't be inspected
continue
@ -72,7 +74,7 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
ribbon_config = {}
# Define tab order (tabs will appear in this order)
tab_order = ['Home', 'Insert', 'Layout', 'Arrange', 'View', 'Export']
tab_order = ["Home", "Insert", "Layout", "Arrange", "View", "Export"]
# Add tabs in the defined order, then add any remaining tabs
all_tabs = list(tabs.keys())
@ -87,12 +89,12 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
# Define group order per tab (if needed)
group_orders = {
'Home': ['File', 'Edit'],
'Insert': ['Media'],
'Layout': ['Navigation', 'Page', 'Templates'],
'Arrange': ['Align', 'Size', 'Distribute'],
'View': ['Zoom'],
'Export': ['Export'],
"Home": ["File", "Edit"],
"Insert": ["Media"],
"Layout": ["Navigation", "Page", "Templates"],
"Arrange": ["Align", "Size", "Distribute"],
"View": ["Zoom"],
"Export": ["Export"],
}
# Get the group order for this tab, or use alphabetical
@ -107,14 +109,9 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
for group_name in group_order:
if group_name in groups_dict:
actions = groups_dict[group_name]
groups_list.append({
'name': group_name,
'actions': actions
})
groups_list.append({"name": group_name, "actions": actions})
ribbon_config[tab_name] = {
'groups': groups_list
}
ribbon_config[tab_name] = {"groups": groups_list}
return ribbon_config
@ -136,12 +133,12 @@ def get_keyboard_shortcuts(window_class: Type) -> Dict[str, str]:
try:
attr = getattr(window_class, attr_name)
if hasattr(attr, '_ribbon_action'):
if hasattr(attr, "_ribbon_action"):
action_data = attr._ribbon_action
shortcut = action_data.get('shortcut')
shortcut = action_data.get("shortcut")
if shortcut:
shortcuts[shortcut] = action_data['action']
shortcuts[shortcut] = action_data["action"]
except (AttributeError, TypeError):
continue
@ -169,11 +166,11 @@ def validate_ribbon_config(config: Dict[str, Any]) -> List[str]:
errors.append(f"Tab '{tab_name}' data must be a dictionary")
continue
if 'groups' not in tab_data:
if "groups" not in tab_data:
errors.append(f"Tab '{tab_name}' missing 'groups' key")
continue
groups = tab_data['groups']
groups = tab_data["groups"]
if not isinstance(groups, list):
errors.append(f"Tab '{tab_name}' groups must be a list")
continue
@ -183,14 +180,14 @@ def validate_ribbon_config(config: Dict[str, Any]) -> List[str]:
errors.append(f"Tab '{tab_name}' group {i} must be a dictionary")
continue
if 'name' not in group:
if "name" not in group:
errors.append(f"Tab '{tab_name}' group {i} missing 'name'")
if 'actions' not in group:
if "actions" not in group:
errors.append(f"Tab '{tab_name}' group {i} missing 'actions'")
continue
actions = group['actions']
actions = group["actions"]
if not isinstance(actions, list):
errors.append(f"Tab '{tab_name}' group {i} actions must be a list")
continue
@ -200,12 +197,10 @@ def validate_ribbon_config(config: Dict[str, Any]) -> List[str]:
errors.append(f"Tab '{tab_name}' group {i} action {j} must be a dictionary")
continue
required_keys = ['label', 'action', 'tooltip']
required_keys = ["label", "action", "tooltip"]
for key in required_keys:
if key not in action:
errors.append(
f"Tab '{tab_name}' group {i} action {j} missing '{key}'"
)
errors.append(f"Tab '{tab_name}' group {i} action {j} missing '{key}'")
return errors
@ -220,12 +215,8 @@ def print_ribbon_summary(config: Dict[str, Any]):
print("\n=== Ribbon Configuration Summary ===\n")
total_tabs = len(config)
total_groups = sum(len(tab_data['groups']) for tab_data in config.values())
total_actions = sum(
len(group['actions'])
for tab_data in config.values()
for group in tab_data['groups']
)
total_groups = sum(len(tab_data["groups"]) for tab_data in config.values())
total_actions = sum(len(group["actions"]) for tab_data in config.values() for group in tab_data["groups"])
print(f"Total Tabs: {total_tabs}")
print(f"Total Groups: {total_groups}")
@ -233,9 +224,9 @@ def print_ribbon_summary(config: Dict[str, Any]):
for tab_name, tab_data in config.items():
print(f"📑 {tab_name}")
for group in tab_data['groups']:
for group in tab_data["groups"]:
print(f" 📦 {group['name']} ({len(group['actions'])} actions)")
for action in group['actions']:
shortcut = f" ({action['shortcut']})" if action.get('shortcut') else ""
for action in group["actions"]:
shortcut = f" ({action['shortcut']})" if action.get("shortcut") else ""
print(f"{action['label']}{shortcut}")
print()

View File

@ -17,6 +17,7 @@ class RibbonWidget(QWidget):
# Use provided config or fall back to importing the old one
if ribbon_config is None:
from ribbon_config import RIBBON_CONFIG
self.ribbon_config = RIBBON_CONFIG
else:
self.ribbon_config = ribbon_config

View File

@ -11,28 +11,24 @@ from dataclasses import dataclass
@dataclass
class Guide:
"""Represents a snapping guide (vertical or horizontal line)"""
position: float # Position in mm
orientation: str # 'vertical' or 'horizontal'
def serialize(self) -> dict:
"""Serialize guide to dictionary"""
return {
"position": self.position,
"orientation": self.orientation
}
return {"position": self.position, "orientation": self.orientation}
@staticmethod
def deserialize(data: dict) -> 'Guide':
def deserialize(data: dict) -> "Guide":
"""Deserialize guide from dictionary"""
return Guide(
position=data.get("position", 0),
orientation=data.get("orientation", "vertical")
)
return Guide(position=data.get("position", 0), orientation=data.get("orientation", "vertical"))
@dataclass
class SnapResizeParams:
"""Parameters for snap resize operations"""
position: Tuple[float, float]
size: Tuple[float, float]
dx: float
@ -75,12 +71,14 @@ class SnappingSystem:
"""Remove all guides"""
self.guides.clear()
def snap_position(self,
position: Tuple[float, float],
size: Tuple[float, float],
page_size: Tuple[float, float],
dpi: int = 300,
project=None) -> Tuple[float, float]:
def snap_position(
self,
position: Tuple[float, float],
size: Tuple[float, float],
page_size: Tuple[float, float],
dpi: int = 300,
project=None,
) -> Tuple[float, float]:
"""
Apply snapping to a position using combined distance threshold
@ -124,20 +122,24 @@ class SnappingSystem:
page_height_px = page_height_mm * dpi / 25.4
# Corners where element's top-left can snap
snap_points.extend([
(0, 0), # Top-left corner
(page_width_px - width, 0), # Top-right corner
(0, page_height_px - height), # Bottom-left corner
(page_width_px - width, page_height_px - height), # Bottom-right corner
])
snap_points.extend(
[
(0, 0), # Top-left corner
(page_width_px - width, 0), # Top-right corner
(0, page_height_px - height), # Bottom-left corner
(page_width_px - width, page_height_px - height), # Bottom-right corner
]
)
# Edge positions (element aligned to edge on one axis)
snap_points.extend([
(0, y), # Left edge
(page_width_px - width, y), # Right edge
(x, 0), # Top edge
(x, page_height_px - height), # Bottom edge
])
snap_points.extend(
[
(0, y), # Left edge
(page_width_px - width, y), # Right edge
(x, 0), # Top edge
(x, page_height_px - height), # Bottom edge
]
)
# 2. Grid snap points
if snap_to_grid:
@ -166,8 +168,8 @@ class SnappingSystem:
# 3. Guide snap points
if snap_to_guides:
vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'vertical']
horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'horizontal']
vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == "vertical"]
horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == "horizontal"]
# Guide intersections (when both vertical and horizontal guides exist)
for vg in vertical_guides:
@ -222,21 +224,21 @@ class SnappingSystem:
new_width, new_height = width, height
# Apply resize based on handle
if params.resize_handle in ['nw', 'n', 'ne']:
if params.resize_handle in ["nw", "n", "ne"]:
# Top edge moving
new_y = y + params.dy
new_height = height - params.dy
if params.resize_handle in ['sw', 's', 'se']:
if params.resize_handle in ["sw", "s", "se"]:
# Bottom edge moving
new_height = height + params.dy
if params.resize_handle in ['nw', 'w', 'sw']:
if params.resize_handle in ["nw", "w", "sw"]:
# Left edge moving
new_x = x + params.dx
new_width = width - params.dx
if params.resize_handle in ['ne', 'e', 'se']:
if params.resize_handle in ["ne", "e", "se"]:
# Right edge moving
new_width = width + params.dx
@ -244,10 +246,10 @@ class SnappingSystem:
# Use _snap_edge_to_targets consistently for all edges
# Snap left edge (for nw, w, sw handles)
if params.resize_handle in ['nw', 'w', 'sw']:
if params.resize_handle in ["nw", "w", "sw"]:
# Try to snap the left edge
snapped_left = self._snap_edge_to_targets(
new_x, page_width_mm, params.dpi, snap_threshold_px, 'vertical', params.project
new_x, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project
)
if snapped_left is not None:
# Adjust width to compensate for position change
@ -256,21 +258,21 @@ class SnappingSystem:
new_width += width_adjustment
# Snap right edge (for ne, e, se handles)
if params.resize_handle in ['ne', 'e', 'se']:
if params.resize_handle in ["ne", "e", "se"]:
# Calculate right edge position
right_edge = new_x + new_width
# Try to snap the right edge
snapped_right = self._snap_edge_to_targets(
right_edge, page_width_mm, params.dpi, snap_threshold_px, 'vertical', params.project
right_edge, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project
)
if snapped_right is not None:
new_width = snapped_right - new_x
# Snap top edge (for nw, n, ne handles)
if params.resize_handle in ['nw', 'n', 'ne']:
if params.resize_handle in ["nw", "n", "ne"]:
# Try to snap the top edge
snapped_top = self._snap_edge_to_targets(
new_y, page_height_mm, params.dpi, snap_threshold_px, 'horizontal', params.project
new_y, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project
)
if snapped_top is not None:
# Adjust height to compensate for position change
@ -279,12 +281,12 @@ class SnappingSystem:
new_height += height_adjustment
# Snap bottom edge (for sw, s, se handles)
if params.resize_handle in ['sw', 's', 'se']:
if params.resize_handle in ["sw", "s", "se"]:
# Calculate bottom edge position
bottom_edge = new_y + new_height
# Try to snap the bottom edge
snapped_bottom = self._snap_edge_to_targets(
bottom_edge, page_height_mm, params.dpi, snap_threshold_px, 'horizontal', params.project
bottom_edge, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project
)
if snapped_bottom is not None:
new_height = snapped_bottom - new_y
@ -296,13 +298,15 @@ class SnappingSystem:
return ((new_x, new_y), (new_width, new_height))
def _snap_edge_to_targets(self,
edge_position: float,
page_size_mm: float,
dpi: int,
snap_threshold_px: float,
orientation: str,
project=None) -> Optional[float]:
def _snap_edge_to_targets(
self,
edge_position: float,
page_size_mm: float,
dpi: int,
snap_threshold_px: float,
orientation: str,
project=None,
) -> Optional[float]:
"""
Snap an edge position to available targets (grid, edges, guides)
@ -366,13 +370,9 @@ class SnappingSystem:
return best_snap
def _snap_axis(self,
position: float,
size: float,
page_size_mm: float,
dpi: int,
snap_threshold_px: float,
orientation: str) -> float:
def _snap_axis(
self, position: float, size: float, page_size_mm: float, dpi: int, snap_threshold_px: float, orientation: str
) -> float:
"""
Snap along a single axis
@ -453,11 +453,7 @@ class SnappingSystem:
page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4
result = {
'grid': [],
'edges': [],
'guides': []
}
result = {"grid": [], "edges": [], "guides": []}
# Grid lines
if self.snap_to_grid:
@ -466,29 +462,26 @@ class SnappingSystem:
# Vertical grid lines
x = 0
while x <= page_width_px:
result['grid'].append(('vertical', x))
result["grid"].append(("vertical", x))
x += grid_size_px
# Horizontal grid lines
y = 0
while y <= page_height_px:
result['grid'].append(('horizontal', y))
result["grid"].append(("horizontal", y))
y += grid_size_px
# Edge lines
if self.snap_to_edges:
result['edges'].extend([
('vertical', 0),
('vertical', page_width_px),
('horizontal', 0),
('horizontal', page_height_px)
])
result["edges"].extend(
[("vertical", 0), ("vertical", page_width_px), ("horizontal", 0), ("horizontal", page_height_px)]
)
# Guide lines
if self.snap_to_guides:
for guide in self.guides:
guide_pos_px = guide.position * dpi / 25.4
result['guides'].append((guide.orientation, guide_pos_px))
result["guides"].append((guide.orientation, guide_pos_px))
return result
@ -500,7 +493,7 @@ class SnappingSystem:
"snap_to_grid": self.snap_to_grid,
"snap_to_edges": self.snap_to_edges,
"snap_to_guides": self.snap_to_guides,
"guides": [guide.serialize() for guide in self.guides]
"guides": [guide.serialize() for guide in self.guides],
}
def deserialize(self, data: dict):

View File

@ -14,7 +14,9 @@ from pyPhotoAlbum.project import Page
class Template:
"""Class representing a page layout template"""
def __init__(self, name: str = "Untitled Template", description: str = "", page_size_mm: Tuple[float, float] = (210, 297)):
def __init__(
self, name: str = "Untitled Template", description: str = "", page_size_mm: Tuple[float, float] = (210, 297)
):
self.name = name
self.description = description
self.page_size_mm = page_size_mm
@ -30,16 +32,16 @@ class Template:
"name": self.name,
"description": self.description,
"page_size_mm": self.page_size_mm,
"elements": [elem.serialize() for elem in self.elements]
"elements": [elem.serialize() for elem in self.elements],
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Template':
def from_dict(cls, data: Dict[str, Any]) -> "Template":
"""Deserialize template from dictionary"""
template = cls(
name=data.get("name", "Untitled Template"),
description=data.get("description", ""),
page_size_mm=tuple(data.get("page_size_mm", (210, 297)))
page_size_mm=tuple(data.get("page_size_mm", (210, 297))),
)
# Deserialize elements
@ -59,13 +61,13 @@ class Template:
def save_to_file(self, file_path: str):
"""Save template to JSON file"""
with open(file_path, 'w') as f:
with open(file_path, "w") as f:
json.dump(self.to_dict(), f, indent=2)
@classmethod
def load_from_file(cls, file_path: str) -> 'Template':
def load_from_file(cls, file_path: str) -> "Template":
"""Load template from JSON file"""
with open(file_path, 'r') as f:
with open(file_path, "r") as f:
data = json.load(f)
return cls.from_dict(data)
@ -212,11 +214,7 @@ class TemplateManager:
Create a template from an existing page.
Converts all ImageData elements to PlaceholderData.
"""
template = Template(
name=name,
description=description,
page_size_mm=page.layout.size
)
template = Template(name=name, description=description, page_size_mm=page.layout.size)
# Convert elements
for element in page.layout.elements:
@ -229,7 +227,7 @@ class TemplateManager:
width=element.size[0],
height=element.size[1],
rotation=element.rotation,
z_index=element.z_index
z_index=element.z_index,
)
template.add_element(placeholder)
elif isinstance(element, TextBoxData):
@ -243,7 +241,7 @@ class TemplateManager:
width=element.size[0],
height=element.size[1],
rotation=element.rotation,
z_index=element.z_index
z_index=element.z_index,
)
template.add_element(text_box)
elif isinstance(element, PlaceholderData):
@ -256,7 +254,7 @@ class TemplateManager:
width=element.size[0],
height=element.size[1],
rotation=element.rotation,
z_index=element.z_index
z_index=element.z_index,
)
template.add_element(placeholder)
@ -268,7 +266,7 @@ class TemplateManager:
from_size: Tuple[float, float],
to_size: Tuple[float, float],
scale_mode: str = "proportional",
margin_percent: float = 0.0
margin_percent: float = 0.0,
) -> List[BaseLayoutElement]:
"""
Scale template elements to fit target page size with adjustable margins.
@ -323,14 +321,13 @@ class TemplateManager:
# Create a new element of the same type
if isinstance(element, PlaceholderData):
new_elem = PlaceholderData(
placeholder_type=element.placeholder_type,
default_content=element.default_content
placeholder_type=element.placeholder_type, default_content=element.default_content
)
elif isinstance(element, TextBoxData):
new_elem = TextBoxData(
text_content=element.text_content,
font_settings=element.font_settings.copy() if element.font_settings else None,
alignment=element.alignment
alignment=element.alignment,
)
else:
continue # Skip other types
@ -339,14 +336,8 @@ class TemplateManager:
old_x, old_y = element.position
old_w, old_h = element.size
new_elem.position = (
old_x * scale_x + offset_x,
old_y * scale_y + offset_y
)
new_elem.size = (
old_w * scale_x,
old_h * scale_y
)
new_elem.position = (old_x * scale_x + offset_x, old_y * scale_y + offset_y)
new_elem.size = (old_w * scale_x, old_h * scale_y)
new_elem.rotation = element.rotation
new_elem.z_index = element.z_index
@ -362,15 +353,9 @@ class TemplateManager:
for elem in scaled_elements:
# Convert position from mm to pixels
elem.position = (
elem.position[0] * mm_to_px,
elem.position[1] * mm_to_px
)
elem.position = (elem.position[0] * mm_to_px, elem.position[1] * mm_to_px)
# Convert size from mm to pixels
elem.size = (
elem.size[0] * mm_to_px,
elem.size[1] * mm_to_px
)
elem.size = (elem.size[0] * mm_to_px, elem.size[1] * mm_to_px)
return scaled_elements
@ -381,7 +366,7 @@ class TemplateManager:
mode: str = "replace",
scale_mode: str = "proportional",
margin_percent: float = 2.5,
auto_embed: bool = True
auto_embed: bool = True,
):
"""
Apply template to an existing page with adjustable margins.
@ -406,11 +391,7 @@ class TemplateManager:
# Scale template elements to fit page
scaled_elements = self.scale_template_elements(
template.elements,
template.page_size_mm,
page.layout.size,
scale_mode,
margin_percent
template.elements, template.page_size_mm, page.layout.size, scale_mode, margin_percent
)
# Add scaled elements to page
@ -424,11 +405,7 @@ class TemplateManager:
# Get template placeholders (scaled)
scaled_elements = self.scale_template_elements(
template.elements,
template.page_size_mm,
page.layout.size,
scale_mode,
margin_percent
template.elements, template.page_size_mm, page.layout.size, scale_mode, margin_percent
)
template_placeholders = [e for e in scaled_elements if isinstance(e, PlaceholderData)]
@ -451,7 +428,7 @@ class TemplateManager:
page.layout.add_element(placeholder)
# Add remaining images (if any) at their original positions
for img in existing_images[len(template_placeholders):]:
for img in existing_images[len(template_placeholders) :]:
page.layout.add_element(img)
# Add template text boxes
@ -465,7 +442,7 @@ class TemplateManager:
target_size_mm: Optional[Tuple[float, float]] = None,
scale_mode: str = "proportional",
margin_percent: float = 2.5,
auto_embed: bool = True
auto_embed: bool = True,
) -> Page:
"""
Create a new page from a template.
@ -494,11 +471,7 @@ class TemplateManager:
page_size = target_size_mm
# Scale template elements with margins
elements = self.scale_template_elements(
template.elements,
template.page_size_mm,
target_size_mm,
scale_mode,
margin_percent
template.elements, template.page_size_mm, target_size_mm, scale_mode, margin_percent
)
# Create new page layout

View File

@ -3,8 +3,15 @@ Text editing dialog for pyPhotoAlbum
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPushButton,
QTextEdit, QLabel, QComboBox, QSpinBox, QColorDialog
QDialog,
QVBoxLayout,
QHBoxLayout,
QPushButton,
QTextEdit,
QLabel,
QComboBox,
QSpinBox,
QColorDialog,
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFont, QColor
@ -42,10 +49,9 @@ class TextEditDialog(QDialog):
# Font family
font_layout.addWidget(QLabel("Font:"))
self.font_combo = QComboBox()
self.font_combo.addItems([
"Arial", "Times New Roman", "Courier New",
"Helvetica", "Verdana", "Georgia", "Comic Sans MS"
])
self.font_combo.addItems(
["Arial", "Times New Roman", "Courier New", "Helvetica", "Verdana", "Georgia", "Comic Sans MS"]
)
font_layout.addWidget(self.font_combo)
# Font size
@ -96,26 +102,22 @@ class TextEditDialog(QDialog):
self.text_edit.setPlainText(self.text_element.text_content)
# Load font settings
font_family = self.text_element.font_settings.get('family', 'Arial')
font_family = self.text_element.font_settings.get("family", "Arial")
index = self.font_combo.findText(font_family)
if index >= 0:
self.font_combo.setCurrentIndex(index)
font_size = self.text_element.font_settings.get('size', 12)
font_size = self.text_element.font_settings.get("size", 12)
self.font_size_spin.setValue(int(font_size))
# Load color
color = self.text_element.font_settings.get('color', (0, 0, 0))
color = self.text_element.font_settings.get("color", (0, 0, 0))
if all(isinstance(c, int) and c > 1 for c in color):
# Color in 0-255 range
self.current_color = QColor(*color)
else:
# Color in 0-1 range
self.current_color = QColor(
int(color[0] * 255),
int(color[1] * 255),
int(color[2] * 255)
)
self.current_color = QColor(int(color[0] * 255), int(color[1] * 255), int(color[2] * 255))
self._update_color_button()
# Load alignment
@ -141,15 +143,11 @@ class TextEditDialog(QDialog):
def get_values(self):
"""Get the edited values"""
return {
'text_content': self.text_edit.toPlainText(),
'font_settings': {
'family': self.font_combo.currentText(),
'size': self.font_size_spin.value(),
'color': (
self.current_color.red(),
self.current_color.green(),
self.current_color.blue()
)
"text_content": self.text_edit.toPlainText(),
"font_settings": {
"family": self.font_combo.currentText(),
"size": self.font_size_spin.value(),
"color": (self.current_color.red(), self.current_color.green(), self.current_color.blue()),
},
'alignment': self.alignment_combo.currentText()
"alignment": self.alignment_combo.currentText(),
}

View File

@ -24,7 +24,7 @@ VERSION_HISTORY = {
"released": "2025-01-11",
"breaking_changes": [
"Asset paths changed from absolute/full-project-relative to project-relative",
"Added automatic path normalization for legacy projects"
"Added automatic path normalization for legacy projects",
],
"compatible_with": ["1.0", "2.0"], # 2.0 can read 1.0 with migration
},
@ -38,7 +38,7 @@ VERSION_HISTORY = {
"Added deletion tracking (deleted flag and deleted_at timestamp)",
],
"compatible_with": ["1.0", "2.0", "3.0"], # 3.0 can read older versions with migration
}
},
}
@ -120,9 +120,11 @@ class DataMigration:
@classmethod
def register_migration(cls, from_version: str, to_version: str):
"""Decorator to register a migration function"""
def decorator(func):
cls._migrations[(from_version, to_version)] = func
return func
return decorator
@classmethod
@ -164,6 +166,7 @@ class DataMigration:
# Register migrations
@DataMigration.register_migration("1.0", "2.0")
def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]:
"""
@ -176,7 +179,7 @@ def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]:
print("Migration 1.0 → 2.0: Asset paths will be normalized during load")
# Update version in data
data['data_version'] = "2.0"
data["data_version"] = "2.0"
# Note: Actual path normalization is handled in load_from_zip
# This migration mainly updates the version number
@ -249,7 +252,7 @@ def migrate_2_0_to_3_0(data: Dict[str, Any]) -> Dict[str, Any]:
element_data["deleted_at"] = None
# Update version
data['data_version'] = "3.0"
data["data_version"] = "3.0"
print(f" Migrated {len(data.get('pages', []))} pages to v3.0")
@ -287,7 +290,7 @@ def check_version_compatibility(file_version: str, file_path: str = "") -> tuple
error_msg += f"File version info:\n"
error_msg += f" Description: {file_info.get('description', 'Unknown')}\n"
error_msg += f" Released: {file_info.get('released', 'Unknown')}\n"
breaking_changes = file_info.get('breaking_changes', [])
breaking_changes = file_info.get("breaking_changes", [])
if breaking_changes:
error_msg += f" Breaking changes:\n"
for change in breaking_changes:
@ -312,7 +315,7 @@ def format_version_info() -> str:
info.append(f" Description: {version_info.get('description', 'Unknown')}")
info.append(f" Released: {version_info.get('released', 'Unknown')}")
breaking_changes = version_info.get('breaking_changes', [])
breaking_changes = version_info.get("breaking_changes", [])
if breaking_changes:
info.append(f" Breaking changes:")
for change in breaking_changes:

View File

@ -15,9 +15,9 @@ from pyPhotoAlbum.project import Project, Page
@pytest.fixture
def temp_image_file():
"""Create a temporary test image file"""
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
# Create a simple test image
img = Image.new('RGB', (100, 100), color='red')
img = Image.new("RGB", (100, 100), color="red")
img.save(f.name)
yield f.name
# Cleanup
@ -37,37 +37,19 @@ def temp_dir():
@pytest.fixture
def sample_image_data(temp_image_file):
"""Create a sample ImageData instance"""
return ImageData(
image_path=temp_image_file,
x=10.0,
y=20.0,
width=100.0,
height=150.0
)
return ImageData(image_path=temp_image_file, x=10.0, y=20.0, width=100.0, height=150.0)
@pytest.fixture
def sample_placeholder_data():
"""Create a sample PlaceholderData instance"""
return PlaceholderData(
placeholder_type="image",
x=50.0,
y=60.0,
width=200.0,
height=150.0
)
return PlaceholderData(placeholder_type="image", x=50.0, y=60.0, width=200.0, height=150.0)
@pytest.fixture
def sample_textbox_data():
"""Create a sample TextBoxData instance"""
return TextBoxData(
text_content="Sample Text",
x=30.0,
y=40.0,
width=150.0,
height=50.0
)
return TextBoxData(text_content="Sample Text", x=30.0, y=40.0, width=150.0, height=50.0)
@pytest.fixture
@ -119,10 +101,7 @@ def mock_main_window():
window.project = Project(name="Test Project")
# Add a test page
page = Page(
layout=PageLayout(width=210, height=297), # A4 size in mm
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1) # A4 size in mm
window.project.pages.append(page)
window.project.working_dpi = 96
window.project.page_size_mm = (210, 297)
@ -139,39 +118,19 @@ def mock_main_window():
@pytest.fixture
def sample_image_element():
"""Create a sample ImageData element for testing"""
return ImageData(
image_path="test.jpg",
x=100,
y=100,
width=200,
height=150,
z_index=1
)
return ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150, z_index=1)
@pytest.fixture
def sample_placeholder_element():
"""Create a sample PlaceholderData element for testing"""
return PlaceholderData(
x=50,
y=50,
width=100,
height=100,
z_index=0
)
return PlaceholderData(x=50, y=50, width=100, height=100, z_index=0)
@pytest.fixture
def sample_textbox_element():
"""Create a sample TextBoxData element for testing"""
return TextBoxData(
x=10,
y=10,
width=180,
height=50,
text_content="Test Text",
z_index=2
)
return TextBoxData(x=10, y=10, width=180, height=50, text_content="Test Text", z_index=2)
@pytest.fixture
@ -189,19 +148,19 @@ def mock_page_renderer():
# Mock coordinate conversion methods
def page_to_screen(x, y):
return (renderer.screen_x + x * renderer.zoom,
renderer.screen_y + y * renderer.zoom)
return (renderer.screen_x + x * renderer.zoom, renderer.screen_y + y * renderer.zoom)
def screen_to_page(x, y):
return ((x - renderer.screen_x) / renderer.zoom,
(y - renderer.screen_y) / renderer.zoom)
return ((x - renderer.screen_x) / renderer.zoom, (y - renderer.screen_y) / renderer.zoom)
def is_point_in_page(x, y):
# Simple bounds check (assume 210mm x 297mm page at 96 DPI)
page_width_px = 210 * 96 / 25.4
page_height_px = 297 * 96 / 25.4
return (renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom and
renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom)
return (
renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom
and renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom
)
renderer.page_to_screen = page_to_screen
renderer.screen_to_page = screen_to_page
@ -213,8 +172,8 @@ def mock_page_renderer():
@pytest.fixture
def create_mouse_event():
"""Factory fixture for creating QMouseEvent objects"""
def _create_event(event_type, x, y, button=Qt.MouseButton.LeftButton,
modifiers=Qt.KeyboardModifier.NoModifier):
def _create_event(event_type, x, y, button=Qt.MouseButton.LeftButton, modifiers=Qt.KeyboardModifier.NoModifier):
"""Create a QMouseEvent for testing
Args:
@ -224,19 +183,15 @@ def create_mouse_event():
modifiers: Keyboard modifiers
"""
pos = QPointF(x, y)
return QMouseEvent(
event_type,
pos,
button,
button,
modifiers
)
return QMouseEvent(event_type, pos, button, button, modifiers)
return _create_event
@pytest.fixture
def create_wheel_event():
"""Factory fixture for creating QWheelEvent objects"""
def _create_event(x, y, delta_y=120, modifiers=Qt.KeyboardModifier.NoModifier):
"""Create a QWheelEvent for testing
@ -257,38 +212,22 @@ def create_wheel_event():
Qt.MouseButton.NoButton,
modifiers,
Qt.ScrollPhase.NoScrollPhase,
False
False,
)
return _create_event
@pytest.fixture
def populated_page():
"""Create a page with multiple elements for testing"""
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
# Add various elements
page.layout.add_element(ImageData(
image_path="img1.jpg",
x=10, y=10,
width=100, height=75,
z_index=0
))
page.layout.add_element(ImageData(image_path="img1.jpg", x=10, y=10, width=100, height=75, z_index=0))
page.layout.add_element(PlaceholderData(
x=120, y=10,
width=80, height=60,
z_index=1
))
page.layout.add_element(PlaceholderData(x=120, y=10, width=80, height=60, z_index=1))
page.layout.add_element(TextBoxData(
x=10, y=100,
width=190, height=40,
text_content="Sample Text",
z_index=2
))
page.layout.add_element(TextBoxData(x=10, y=100, width=190, height=40, text_content="Sample Text", z_index=2))
return page

View File

@ -72,8 +72,8 @@ class TestAlignmentManager:
def test_align_right_multiple_elements(self):
"""Test align_right with multiple elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50) # right edge at 150
elem2 = ImageData(x=30, y=40, width=80, height=60) # right edge at 110
elem3 = ImageData(x=70, y=60, width=90, height=40) # right edge at 160
elem2 = ImageData(x=30, y=40, width=80, height=60) # right edge at 110
elem3 = ImageData(x=70, y=60, width=90, height=40) # right edge at 160
changes = AlignmentManager.align_right([elem1, elem2, elem3])
@ -108,8 +108,8 @@ class TestAlignmentManager:
def test_align_bottom_multiple_elements(self):
"""Test align_bottom with multiple elements"""
elem1 = ImageData(x=50, y=30, width=100, height=50) # bottom at 80
elem2 = ImageData(x=30, y=20, width=80, height=60) # bottom at 80
elem3 = ImageData(x=70, y=40, width=90, height=50) # bottom at 90
elem2 = ImageData(x=30, y=20, width=80, height=60) # bottom at 80
elem3 = ImageData(x=70, y=40, width=90, height=50) # bottom at 90
changes = AlignmentManager.align_bottom([elem1, elem2, elem3])
@ -125,17 +125,17 @@ class TestAlignmentManager:
def test_align_horizontal_center_multiple_elements(self):
"""Test align_horizontal_center with multiple elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 100
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
elem3 = ImageData(x=70, y=60, width=60, height=40) # center at 100
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 100
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
elem3 = ImageData(x=70, y=60, width=60, height=40) # center at 100
changes = AlignmentManager.align_horizontal_center([elem1, elem2, elem3])
# Average center = (100 + 70 + 100) / 3 = 90
# All elements should center at x=90
assert abs(elem1.position[0] + elem1.size[0]/2 - 90) < 0.01
assert abs(elem2.position[0] + elem2.size[0]/2 - 90) < 0.01
assert abs(elem3.position[0] + elem3.size[0]/2 - 90) < 0.01
assert abs(elem1.position[0] + elem1.size[0] / 2 - 90) < 0.01
assert abs(elem2.position[0] + elem2.size[0] / 2 - 90) < 0.01
assert abs(elem3.position[0] + elem3.size[0] / 2 - 90) < 0.01
# Y positions should not change
assert elem1.position[1] == 20
@ -144,17 +144,17 @@ class TestAlignmentManager:
def test_align_vertical_center_multiple_elements(self):
"""Test align_vertical_center with multiple elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 45
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
elem3 = ImageData(x=70, y=30, width=60, height=40) # center at 50
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 45
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
elem3 = ImageData(x=70, y=30, width=60, height=40) # center at 50
changes = AlignmentManager.align_vertical_center([elem1, elem2, elem3])
# Average center = (45 + 70 + 50) / 3 = 55
# All elements should center at y=55
assert abs(elem1.position[1] + elem1.size[1]/2 - 55) < 0.01
assert abs(elem2.position[1] + elem2.size[1]/2 - 55) < 0.01
assert abs(elem3.position[1] + elem3.size[1]/2 - 55) < 0.01
assert abs(elem1.position[1] + elem1.size[1] / 2 - 55) < 0.01
assert abs(elem2.position[1] + elem2.size[1] / 2 - 55) < 0.01
assert abs(elem3.position[1] + elem3.size[1] / 2 - 55) < 0.01
# X positions should not change
assert elem1.position[0] == 50
@ -452,7 +452,7 @@ class TestAlignmentManager:
assert len(changes) == 1
assert changes[0][0] == elem
assert changes[0][1] == (100, 80) # old position
assert changes[0][2] == (20, 15) # old size
assert changes[0][2] == (20, 15) # old size
def test_maximize_pattern_two_elements_horizontal(self):
"""Test maximize_pattern with two elements side by side"""
@ -469,11 +469,11 @@ class TestAlignmentManager:
# Elements should not overlap (min_gap = 2.0)
gap_x = max(
elem2.position[0] - (elem1.position[0] + elem1.size[0]),
elem1.position[0] - (elem2.position[0] + elem2.size[0])
elem1.position[0] - (elem2.position[0] + elem2.size[0]),
)
gap_y = max(
elem2.position[1] - (elem1.position[1] + elem1.size[1]),
elem1.position[1] - (elem2.position[1] + elem2.size[1])
elem1.position[1] - (elem2.position[1] + elem2.size[1]),
)
# Either horizontal or vertical gap should be >= min_gap
@ -510,11 +510,11 @@ class TestAlignmentManager:
# Calculate gaps between rectangles
gap_x = max(
elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]),
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0])
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]),
)
gap_y = max(
elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]),
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1])
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]),
)
# At least one gap should be >= min_gap
@ -564,11 +564,7 @@ class TestAlignmentManager:
elem4 = ImageData(x=140, y=90, width=10, height=10)
page_size = (160, 110)
changes = AlignmentManager.maximize_pattern(
[elem1, elem2, elem3, elem4],
page_size,
min_gap=2.0
)
changes = AlignmentManager.maximize_pattern([elem1, elem2, elem3, elem4], page_size, min_gap=2.0)
# All elements should grow
for elem in [elem1, elem2, elem3, elem4]:
@ -584,11 +580,11 @@ class TestAlignmentManager:
gap_x = max(
elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]),
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0])
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]),
)
gap_y = max(
elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]),
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1])
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]),
)
assert gap_x >= 2.0 or gap_y >= 2.0

View File

@ -81,7 +81,7 @@ class TestGetSelectedElementsList:
class TestAlignLeft:
"""Test align_left method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_left_success(self, mock_manager, qtbot):
"""Test aligning elements to the left"""
window = TestAlignmentWindow()
@ -93,10 +93,7 @@ class TestAlignLeft:
window.gl_widget.selected_elements = {element1, element2}
# Mock AlignmentManager to return changes
mock_manager.align_left.return_value = [
(element1, (100, 0)),
(element2, (200, 100))
]
mock_manager.align_left.return_value = [(element1, (100, 0)), (element2, (200, 100))]
window.align_left()
@ -106,7 +103,7 @@ class TestAlignLeft:
assert "aligned" in window._status_message.lower()
assert "left" in window._status_message.lower()
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_left_no_changes(self, mock_manager, qtbot):
"""Test align left when no changes needed"""
window = TestAlignmentWindow()
@ -143,7 +140,7 @@ class TestAlignLeft:
class TestAlignRight:
"""Test align_right method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_right_success(self, mock_manager, qtbot):
"""Test aligning elements to the right"""
window = TestAlignmentWindow()
@ -154,10 +151,7 @@ class TestAlignRight:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_right.return_value = [
(element1, (100, 0)),
(element2, (200, 100))
]
mock_manager.align_right.return_value = [(element1, (100, 0)), (element2, (200, 100))]
window.align_right()
@ -169,7 +163,7 @@ class TestAlignRight:
class TestAlignTop:
"""Test align_top method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_top_success(self, mock_manager, qtbot):
"""Test aligning elements to the top"""
window = TestAlignmentWindow()
@ -180,10 +174,7 @@ class TestAlignTop:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_top.return_value = [
(element1, (0, 50)),
(element2, (100, 100))
]
mock_manager.align_top.return_value = [(element1, (0, 50)), (element2, (100, 100))]
window.align_top()
@ -195,7 +186,7 @@ class TestAlignTop:
class TestAlignBottom:
"""Test align_bottom method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_bottom_success(self, mock_manager, qtbot):
"""Test aligning elements to the bottom"""
window = TestAlignmentWindow()
@ -206,10 +197,7 @@ class TestAlignBottom:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_bottom.return_value = [
(element1, (0, 50)),
(element2, (100, 100))
]
mock_manager.align_bottom.return_value = [(element1, (0, 50)), (element2, (100, 100))]
window.align_bottom()
@ -221,7 +209,7 @@ class TestAlignBottom:
class TestAlignHorizontalCenter:
"""Test align_horizontal_center method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_horizontal_center_success(self, mock_manager, qtbot):
"""Test aligning elements to horizontal center"""
window = TestAlignmentWindow()
@ -232,10 +220,7 @@ class TestAlignHorizontalCenter:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_horizontal_center.return_value = [
(element1, (0, 0)),
(element2, (200, 100))
]
mock_manager.align_horizontal_center.return_value = [(element1, (0, 0)), (element2, (200, 100))]
window.align_horizontal_center()
@ -247,7 +232,7 @@ class TestAlignHorizontalCenter:
class TestAlignVerticalCenter:
"""Test align_vertical_center method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_vertical_center_success(self, mock_manager, qtbot):
"""Test aligning elements to vertical center"""
window = TestAlignmentWindow()
@ -258,10 +243,7 @@ class TestAlignVerticalCenter:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_vertical_center.return_value = [
(element1, (0, 0)),
(element2, (100, 200))
]
mock_manager.align_vertical_center.return_value = [(element1, (0, 0)), (element2, (100, 200))]
window.align_vertical_center()
@ -273,7 +255,7 @@ class TestAlignVerticalCenter:
class TestAlignmentCommandPattern:
"""Test alignment operations with command pattern for undo/redo"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_alignment_creates_command(self, mock_manager, qtbot):
"""Test that alignment creates a command for undo"""
window = TestAlignmentWindow()
@ -284,10 +266,7 @@ class TestAlignmentCommandPattern:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_left.return_value = [
(element1, (100, 0)),
(element2, (200, 100))
]
mock_manager.align_left.return_value = [(element1, (100, 0)), (element2, (200, 100))]
# Should have no commands initially
assert not window.project.history.can_undo()
@ -297,7 +276,7 @@ class TestAlignmentCommandPattern:
# Should have created a command
assert window.project.history.can_undo()
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_alignment_undo_redo(self, mock_manager, qtbot):
"""Test that alignment can be undone and redone"""
window = TestAlignmentWindow()
@ -309,10 +288,7 @@ class TestAlignmentCommandPattern:
window.gl_widget.selected_elements = {element1, element2}
# Mock alignment to return changes (command will handle actual moves)
mock_manager.align_top.return_value = [
(element1, (100, 0)),
(element2, (200, 100))
]
mock_manager.align_top.return_value = [(element1, (100, 0)), (element2, (200, 100))]
# Execute alignment - command created
window.align_top()

View File

@ -28,8 +28,8 @@ class TestAssetDropWidget(AssetDropMixin, AssetPathMixin, PageNavigationMixin, V
def _get_project_folder(self):
"""Override to access project via window mock"""
main_window = self.window()
if hasattr(main_window, 'project') and main_window.project:
return getattr(main_window.project, 'folder_path', None)
if hasattr(main_window, "project") and main_window.project:
return getattr(main_window.project, "folder_path", None)
return None
@ -43,7 +43,7 @@ class TestAssetDropInitialization:
# Should accept drops (set in GLWidget.__init__)
# This is a property of the widget, not the mixin
assert hasattr(widget, 'acceptDrops')
assert hasattr(widget, "acceptDrops")
class TestDragEnterEvent:
@ -141,7 +141,7 @@ class TestDragMoveEvent:
class TestDropEvent:
"""Test dropEvent method"""
@patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand')
@patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand")
def test_drop_creates_image_element(self, mock_cmd_class, qtbot):
"""Test dropping image file creates ImageData element"""
widget = TestAssetDropWidget()
@ -239,7 +239,7 @@ class TestDropEvent:
# Create a real test image file
test_image = tmp_path / "test_image.jpg"
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100) # Minimal JPEG header
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) # Minimal JPEG header
# Setup project with page containing placeholder
mock_window = Mock()
@ -248,6 +248,7 @@ class TestDropEvent:
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
from pyPhotoAlbum.models import PlaceholderData
placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
page.layout.elements.append(placeholder)
@ -280,7 +281,7 @@ class TestDropEvent:
# Image path should now be in assets folder (imported)
assert page.layout.elements[0].image_path.startswith("assets/")
@patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand')
@patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand")
def test_drop_multiple_files(self, mock_cmd_class, qtbot):
"""Test dropping first image from multiple files"""
widget = TestAssetDropWidget()
@ -311,11 +312,13 @@ class TestDropEvent:
# Create drop event with multiple files (only first is used)
mime_data = QMimeData()
mime_data.setUrls([
QUrl.fromLocalFile("/path/to/image1.jpg"),
QUrl.fromLocalFile("/path/to/image2.png"),
QUrl.fromLocalFile("/path/to/image3.jpg")
])
mime_data.setUrls(
[
QUrl.fromLocalFile("/path/to/image1.jpg"),
QUrl.fromLocalFile("/path/to/image2.png"),
QUrl.fromLocalFile("/path/to/image3.jpg"),
]
)
event = Mock()
event.mimeData = Mock(return_value=mime_data)
@ -364,7 +367,7 @@ class TestDropEvent:
# Create a real test image file
test_image = tmp_path / "new_image.jpg"
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100)
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
# Setup project with page containing existing ImageData
mock_window = Mock()
@ -372,10 +375,7 @@ class TestDropEvent:
mock_window.project.working_dpi = 96
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
existing_image = ImageData(
image_path="assets/old_image.jpg",
x=100, y=100, width=200, height=150
)
existing_image = ImageData(image_path="assets/old_image.jpg", x=100, y=100, width=200, height=150)
page.layout.elements.append(existing_image)
mock_window.project.pages = [page]
@ -407,24 +407,19 @@ class TestDropEvent:
widget.update = Mock()
test_image = tmp_path / "test.jpg"
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100)
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
existing_image = ImageData(
image_path="assets/old.jpg",
x=100, y=100, width=200, height=150
)
existing_image = ImageData(image_path="assets/old.jpg", x=100, y=100, width=200, height=150)
page.layout.elements.append(existing_image)
mock_window.project.pages = [page]
# Mock asset manager to raise exception
mock_window.project.asset_manager = Mock()
mock_window.project.asset_manager.import_asset = Mock(
side_effect=Exception("Import failed")
)
mock_window.project.asset_manager.import_asset = Mock(side_effect=Exception("Import failed"))
widget.window = Mock(return_value=mock_window)
widget._get_element_at = Mock(return_value=existing_image)
@ -454,7 +449,7 @@ class TestDropEvent:
# Create a corrupted/invalid image file
corrupted_image = tmp_path / "corrupted.jpg"
corrupted_image.write_bytes(b'not a valid image')
corrupted_image.write_bytes(b"not a valid image")
mock_window = Mock()
mock_window.project = Project(name="Test")
@ -488,7 +483,8 @@ class TestDropEvent:
# Should use default dimensions (200, 150) from _calculate_image_dimensions
# Check that AddElementCommand was called with an ImageData
from pyPhotoAlbum.commands import AddElementCommand
with patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand') as mock_cmd:
with patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand") as mock_cmd:
# Re-run to check the call
widget.dropEvent(event)
assert mock_cmd.called
@ -527,10 +523,7 @@ class TestExtractImagePathEdgeCases:
widget.update = Mock()
mime_data = QMimeData()
mime_data.setUrls([
QUrl.fromLocalFile("/path/to/document.pdf"),
QUrl.fromLocalFile("/path/to/file.txt")
])
mime_data.setUrls([QUrl.fromLocalFile("/path/to/document.pdf"), QUrl.fromLocalFile("/path/to/file.txt")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
@ -576,7 +569,7 @@ class TestPlaceholderReplacementEdgeCases:
widget.update = Mock()
test_image = tmp_path / "test.jpg"
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100)
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
# Setup project WITHOUT pages
mock_window = Mock()
@ -585,6 +578,7 @@ class TestPlaceholderReplacementEdgeCases:
mock_window.project.pages = [] # Empty pages list
from pyPhotoAlbum.models import PlaceholderData
placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
mock_window.project.asset_manager = Mock()

View File

@ -0,0 +1,183 @@
"""
Tests for asset_path mixin module
"""
import pytest
import os
from unittest.mock import Mock
class TestAssetPathMixin:
"""Tests for AssetPathMixin class"""
def test_resolve_asset_path_empty_path(self, tmp_path):
"""Test resolve_asset_path with empty path returns None"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
assert obj.resolve_asset_path("") is None
assert obj.resolve_asset_path(None) is None
def test_resolve_asset_path_absolute_exists(self, tmp_path):
"""Test resolve_asset_path with existing absolute path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
# Create a test file
test_file = tmp_path / "test_image.jpg"
test_file.write_text("test")
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.resolve_asset_path(str(test_file))
assert result == str(test_file)
def test_resolve_asset_path_absolute_not_exists(self, tmp_path):
"""Test resolve_asset_path with non-existing absolute path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.resolve_asset_path("/nonexistent/path/image.jpg")
assert result is None
def test_resolve_asset_path_relative_exists(self, tmp_path):
"""Test resolve_asset_path with existing relative path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
# Create assets folder and test file
assets_dir = tmp_path / "assets"
assets_dir.mkdir()
test_file = assets_dir / "photo.jpg"
test_file.write_text("test")
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.resolve_asset_path("assets/photo.jpg")
assert result == str(test_file)
def test_resolve_asset_path_relative_not_exists(self, tmp_path):
"""Test resolve_asset_path with non-existing relative path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.resolve_asset_path("assets/nonexistent.jpg")
assert result is None
def test_resolve_asset_path_no_project_folder(self):
"""Test resolve_asset_path when project folder is not available"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = None
obj = TestClass()
result = obj.resolve_asset_path("assets/photo.jpg")
assert result is None
def test_get_asset_full_path_with_project(self, tmp_path):
"""Test get_asset_full_path returns correct path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.get_asset_full_path("assets/photo.jpg")
expected = os.path.join(str(tmp_path), "assets/photo.jpg")
assert result == expected
def test_get_asset_full_path_no_project(self):
"""Test get_asset_full_path without project returns None"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = None
obj = TestClass()
result = obj.get_asset_full_path("assets/photo.jpg")
assert result is None
def test_get_asset_full_path_empty_path(self, tmp_path):
"""Test get_asset_full_path with empty path returns None"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
assert obj.get_asset_full_path("") is None
assert obj.get_asset_full_path(None) is None
def test_get_project_folder_with_project(self, tmp_path):
"""Test _get_project_folder returns project folder"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj._get_project_folder()
assert result == str(tmp_path)
def test_get_project_folder_no_project(self):
"""Test _get_project_folder without project returns None"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
pass
obj = TestClass()
result = obj._get_project_folder()
assert result is None
def test_get_project_folder_project_without_folder_path(self):
"""Test _get_project_folder with project missing folder_path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock(spec=[]) # No folder_path attribute
obj = TestClass()
result = obj._get_project_folder()
assert result is None

View File

@ -0,0 +1,635 @@
"""
Tests for async_loading mixin module
"""
import pytest
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch, PropertyMock
class TestAsyncLoadingMixinInit:
"""Tests for AsyncLoadingMixin initialization"""
def test_init_async_loading_creates_cache(self):
"""Test that _init_async_loading creates image cache"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
with (
patch("pyPhotoAlbum.mixins.async_loading.ImageCache") as mock_cache,
patch("pyPhotoAlbum.mixins.async_loading.AsyncImageLoader") as mock_loader,
patch("pyPhotoAlbum.mixins.async_loading.AsyncPDFGenerator") as mock_pdf,
):
mock_loader_instance = Mock()
mock_loader.return_value = mock_loader_instance
mock_pdf_instance = Mock()
mock_pdf.return_value = mock_pdf_instance
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj._init_async_loading()
mock_cache.assert_called_once_with(max_memory_mb=512)
assert hasattr(obj, "image_cache")
def test_init_async_loading_creates_image_loader(self):
"""Test that _init_async_loading creates async image loader"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
with (
patch("pyPhotoAlbum.mixins.async_loading.ImageCache") as mock_cache,
patch("pyPhotoAlbum.mixins.async_loading.AsyncImageLoader") as mock_loader,
patch("pyPhotoAlbum.mixins.async_loading.AsyncPDFGenerator") as mock_pdf,
):
mock_loader_instance = Mock()
mock_loader.return_value = mock_loader_instance
mock_pdf_instance = Mock()
mock_pdf.return_value = mock_pdf_instance
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj._init_async_loading()
mock_loader.assert_called_once()
assert hasattr(obj, "async_image_loader")
mock_loader_instance.start.assert_called_once()
def test_init_async_loading_creates_pdf_generator(self):
"""Test that _init_async_loading creates async PDF generator"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
with (
patch("pyPhotoAlbum.mixins.async_loading.ImageCache") as mock_cache,
patch("pyPhotoAlbum.mixins.async_loading.AsyncImageLoader") as mock_loader,
patch("pyPhotoAlbum.mixins.async_loading.AsyncPDFGenerator") as mock_pdf,
):
mock_loader_instance = Mock()
mock_loader.return_value = mock_loader_instance
mock_pdf_instance = Mock()
mock_pdf.return_value = mock_pdf_instance
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj._init_async_loading()
mock_pdf.assert_called_once()
assert hasattr(obj, "async_pdf_generator")
mock_pdf_instance.start.assert_called_once()
class TestAsyncLoadingMixinCleanup:
"""Tests for AsyncLoadingMixin cleanup"""
def test_cleanup_stops_image_loader(self):
"""Test that _cleanup_async_loading stops image loader"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_pdf_generator = Mock()
obj.image_cache = Mock()
obj._cleanup_async_loading()
obj.async_image_loader.stop.assert_called_once()
def test_cleanup_stops_pdf_generator(self):
"""Test that _cleanup_async_loading stops PDF generator"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_pdf_generator = Mock()
obj.image_cache = Mock()
obj._cleanup_async_loading()
obj.async_pdf_generator.stop.assert_called_once()
def test_cleanup_clears_cache(self):
"""Test that _cleanup_async_loading clears image cache"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_pdf_generator = Mock()
obj.image_cache = Mock()
obj._cleanup_async_loading()
obj.image_cache.clear.assert_called_once()
def test_cleanup_handles_missing_components(self):
"""Test that _cleanup_async_loading handles missing components gracefully"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
# Don't set any async components
# Should not raise
obj._cleanup_async_loading()
class TestOnImageLoaded:
"""Tests for _on_image_loaded callback"""
def test_on_image_loaded_calls_element_callback(self):
"""Test that _on_image_loaded calls element's callback"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def update(self):
pass
obj = TestClass()
mock_image = Mock()
mock_user_data = Mock()
mock_user_data._on_async_image_loaded = Mock()
obj._on_image_loaded(Path("/test/image.jpg"), mock_image, mock_user_data)
mock_user_data._on_async_image_loaded.assert_called_once_with(mock_image)
def test_on_image_loaded_triggers_update(self):
"""Test that _on_image_loaded triggers widget update"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def __init__(self):
self.update_called = False
def update(self):
self.update_called = True
obj = TestClass()
obj._on_image_loaded(Path("/test/image.jpg"), Mock(), None)
assert obj.update_called
def test_on_image_loaded_handles_none_user_data(self):
"""Test that _on_image_loaded handles None user_data"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def update(self):
pass
obj = TestClass()
# Should not raise
obj._on_image_loaded(Path("/test/image.jpg"), Mock(), None)
class TestOnImageLoadFailed:
"""Tests for _on_image_load_failed callback"""
def test_on_image_load_failed_calls_element_callback(self):
"""Test that _on_image_load_failed calls element's callback"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
mock_user_data = Mock()
mock_user_data._on_async_image_load_failed = Mock()
obj._on_image_load_failed(Path("/test/image.jpg"), "Error message", mock_user_data)
mock_user_data._on_async_image_load_failed.assert_called_once_with("Error message")
def test_on_image_load_failed_handles_none_user_data(self):
"""Test that _on_image_load_failed handles None user_data"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
# Should not raise
obj._on_image_load_failed(Path("/test/image.jpg"), "Error", None)
class TestOnPdfProgress:
"""Tests for _on_pdf_progress callback"""
def test_on_pdf_progress_updates_dialog(self):
"""Test that _on_pdf_progress updates progress dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj._pdf_progress_dialog = Mock()
obj._on_pdf_progress(5, 10, "Processing page 5")
obj._pdf_progress_dialog.setValue.assert_called_once_with(5)
obj._pdf_progress_dialog.setLabelText.assert_called_once_with("Processing page 5")
def test_on_pdf_progress_handles_no_dialog(self):
"""Test that _on_pdf_progress handles missing dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
# No _pdf_progress_dialog attribute
# Should not raise
obj._on_pdf_progress(5, 10, "Processing")
class TestOnPdfComplete:
"""Tests for _on_pdf_complete callback"""
def test_on_pdf_complete_closes_dialog(self):
"""Test that _on_pdf_complete closes progress dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def window(self):
return Mock(spec=[])
obj = TestClass()
mock_dialog = Mock()
obj._pdf_progress_dialog = mock_dialog
obj._on_pdf_complete(True, [])
mock_dialog.close.assert_called_once()
assert obj._pdf_progress_dialog is None
def test_on_pdf_complete_shows_success_status(self):
"""Test that _on_pdf_complete shows success status"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
mock_main_window = Mock()
class TestClass(AsyncLoadingMixin):
def window(self):
return mock_main_window
obj = TestClass()
obj._on_pdf_complete(True, [])
mock_main_window.show_status.assert_called_once()
call_args = mock_main_window.show_status.call_args[0]
assert "successfully" in call_args[0]
def test_on_pdf_complete_shows_warnings(self):
"""Test that _on_pdf_complete shows warning count"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
mock_main_window = Mock()
class TestClass(AsyncLoadingMixin):
def window(self):
return mock_main_window
obj = TestClass()
obj._on_pdf_complete(True, ["warning1", "warning2"])
mock_main_window.show_status.assert_called_once()
call_args = mock_main_window.show_status.call_args[0]
assert "2 warnings" in call_args[0]
def test_on_pdf_complete_shows_failure_status(self):
"""Test that _on_pdf_complete shows failure status"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
mock_main_window = Mock()
class TestClass(AsyncLoadingMixin):
def window(self):
return mock_main_window
obj = TestClass()
obj._on_pdf_complete(False, [])
mock_main_window.show_status.assert_called_once()
call_args = mock_main_window.show_status.call_args[0]
assert "failed" in call_args[0]
class TestOnPdfFailed:
"""Tests for _on_pdf_failed callback"""
def test_on_pdf_failed_closes_dialog(self):
"""Test that _on_pdf_failed closes progress dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def window(self):
return Mock(spec=[])
obj = TestClass()
mock_dialog = Mock()
obj._pdf_progress_dialog = mock_dialog
obj._on_pdf_failed("Error occurred")
mock_dialog.close.assert_called_once()
assert obj._pdf_progress_dialog is None
def test_on_pdf_failed_shows_error_status(self):
"""Test that _on_pdf_failed shows error status"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
mock_main_window = Mock()
class TestClass(AsyncLoadingMixin):
def window(self):
return mock_main_window
obj = TestClass()
obj._on_pdf_failed("Something went wrong")
mock_main_window.show_status.assert_called_once()
call_args = mock_main_window.show_status.call_args[0]
assert "failed" in call_args[0]
assert "Something went wrong" in call_args[0]
class TestRequestImageLoad:
"""Tests for request_image_load method"""
def test_request_image_load_no_loader(self):
"""Test request_image_load when loader not initialized"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
mock_image_data = Mock()
# Should not raise
obj.request_image_load(mock_image_data)
def test_request_image_load_empty_path(self):
"""Test request_image_load with empty image path"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
mock_image_data = Mock()
mock_image_data.image_path = ""
obj.request_image_load(mock_image_data)
obj.async_image_loader.request_load.assert_not_called()
def test_request_image_load_non_assets_path_skipped(self):
"""Test request_image_load skips paths outside assets folder"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
mock_image_data = Mock()
mock_image_data.image_path = "/absolute/path/image.jpg"
obj.request_image_load(mock_image_data)
obj.async_image_loader.request_load.assert_not_called()
def test_request_image_load_path_not_resolved(self):
"""Test request_image_load when path resolution fails"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
mock_image_data = Mock()
mock_image_data.image_path = "assets/missing.jpg"
mock_image_data.resolve_image_path.return_value = None
obj.request_image_load(mock_image_data)
obj.async_image_loader.request_load.assert_not_called()
def test_request_image_load_success(self, tmp_path):
"""Test successful request_image_load"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin, LoadPriority
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
# Create actual file
asset_path = tmp_path / "assets" / "photo.jpg"
asset_path.parent.mkdir(parents=True)
asset_path.write_text("test")
mock_image_data = Mock()
mock_image_data.image_path = "assets/photo.jpg"
mock_image_data.resolve_image_path.return_value = str(asset_path)
obj.request_image_load(mock_image_data, priority=LoadPriority.HIGH)
obj.async_image_loader.request_load.assert_called_once()
call_kwargs = obj.async_image_loader.request_load.call_args[1]
assert call_kwargs["priority"] == LoadPriority.HIGH
assert call_kwargs["user_data"] == mock_image_data
class TestExportPdfAsync:
"""Tests for export_pdf_async method"""
def test_export_pdf_async_no_generator(self):
"""Test export_pdf_async when generator not initialized"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
mock_project = Mock()
result = obj.export_pdf_async(mock_project, "/output.pdf")
assert result is False
def test_export_pdf_async_creates_progress_dialog(self, qtbot):
"""Test export_pdf_async creates progress dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
from PyQt6.QtWidgets import QWidget
class TestWidget(QWidget, AsyncLoadingMixin):
pass
widget = TestWidget()
qtbot.addWidget(widget)
widget.async_pdf_generator = Mock()
widget.async_pdf_generator.export_pdf.return_value = True
mock_project = Mock()
mock_project.pages = [Mock(is_cover=False, is_double_spread=False)]
widget.export_pdf_async(mock_project, "/output.pdf")
assert hasattr(widget, "_pdf_progress_dialog")
assert widget._pdf_progress_dialog is not None
def test_export_pdf_async_calls_generator(self, qtbot):
"""Test export_pdf_async calls the PDF generator"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
from PyQt6.QtWidgets import QWidget
class TestWidget(QWidget, AsyncLoadingMixin):
pass
widget = TestWidget()
qtbot.addWidget(widget)
widget.async_pdf_generator = Mock()
widget.async_pdf_generator.export_pdf.return_value = True
mock_project = Mock()
mock_project.pages = []
result = widget.export_pdf_async(mock_project, "/output.pdf", export_dpi=150)
widget.async_pdf_generator.export_pdf.assert_called_once_with(mock_project, "/output.pdf", 150)
assert result is True
class TestOnPdfCancel:
"""Tests for _on_pdf_cancel callback"""
def test_on_pdf_cancel_cancels_export(self):
"""Test that _on_pdf_cancel cancels the export"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_pdf_generator = Mock()
obj._on_pdf_cancel()
obj.async_pdf_generator.cancel_export.assert_called_once()
def test_on_pdf_cancel_handles_no_generator(self):
"""Test that _on_pdf_cancel handles missing generator"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
# No async_pdf_generator
# Should not raise
obj._on_pdf_cancel()
class TestGetAsyncStats:
"""Tests for get_async_stats method"""
def test_get_async_stats_empty(self):
"""Test get_async_stats with no components initialized"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
stats = obj.get_async_stats()
assert stats == {}
def test_get_async_stats_with_loader(self):
"""Test get_async_stats includes loader stats"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_image_loader.get_stats.return_value = {"loaded": 10}
stats = obj.get_async_stats()
assert "image_loader" in stats
assert stats["image_loader"]["loaded"] == 10
def test_get_async_stats_with_pdf_generator(self):
"""Test get_async_stats includes PDF generator stats"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_pdf_generator = Mock()
obj.async_pdf_generator.get_stats.return_value = {"exports": 5}
stats = obj.get_async_stats()
assert "pdf_generator" in stats
assert stats["pdf_generator"]["exports"] == 5
def test_get_async_stats_with_all_components(self):
"""Test get_async_stats includes all component stats"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_image_loader.get_stats.return_value = {"loaded": 10}
obj.async_pdf_generator = Mock()
obj.async_pdf_generator.get_stats.return_value = {"exports": 5}
stats = obj.get_async_stats()
assert "image_loader" in stats
assert "pdf_generator" in stats

View File

@ -0,0 +1,511 @@
"""
Tests for AutosaveManager
"""
import pytest
import json
import tempfile
import shutil
from pathlib import Path
from datetime import datetime, timedelta
from unittest.mock import Mock, patch, MagicMock
from pyPhotoAlbum.autosave_manager import AutosaveManager
class TestAutosaveManagerInit:
"""Tests for AutosaveManager initialization"""
def test_init_creates_checkpoint_directory(self, tmp_path, monkeypatch):
"""Test that init creates the checkpoint directory"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
assert checkpoint_dir.exists()
def test_init_with_existing_directory(self, tmp_path, monkeypatch):
"""Test init when checkpoint directory already exists"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
assert checkpoint_dir.exists()
class TestGetCheckpointPath:
"""Tests for _get_checkpoint_path method"""
def test_get_checkpoint_path_basic(self, tmp_path, monkeypatch):
"""Test basic checkpoint path generation"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
path = manager._get_checkpoint_path("MyProject")
assert path.parent == checkpoint_dir
assert path.suffix == ".ppz"
assert "checkpoint_MyProject_" in path.name
def test_get_checkpoint_path_with_timestamp(self, tmp_path, monkeypatch):
"""Test checkpoint path with specific timestamp"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
timestamp = datetime(2024, 1, 15, 10, 30, 45)
path = manager._get_checkpoint_path("TestProject", timestamp)
assert "20240115_103045" in path.name
def test_get_checkpoint_path_sanitizes_name(self, tmp_path, monkeypatch):
"""Test that special characters in project name are sanitized"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
path = manager._get_checkpoint_path("My Project!@#$%")
# Should not contain special characters except - and _
name_without_ext = path.stem
for char in name_without_ext:
assert char.isalnum() or char in "-_", f"Invalid char: {char}"
class TestCreateCheckpoint:
"""Tests for create_checkpoint method"""
def test_create_checkpoint_success(self, tmp_path, monkeypatch):
"""Test successful checkpoint creation"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
# Mock save_to_zip - note the return value format
with patch("pyPhotoAlbum.autosave_manager.save_to_zip") as mock_save:
mock_save.return_value = (True, "Success")
mock_project = Mock()
mock_project.name = "TestProject"
mock_project.file_path = "/path/to/project.ppz"
success, message = manager.create_checkpoint(mock_project)
assert success is True
assert "Checkpoint created" in message
mock_save.assert_called_once()
def test_create_checkpoint_failure(self, tmp_path, monkeypatch):
"""Test checkpoint creation failure"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
with patch("pyPhotoAlbum.autosave_manager.save_to_zip") as mock_save:
mock_save.return_value = (False, "Disk full")
mock_project = Mock()
mock_project.name = "TestProject"
success, message = manager.create_checkpoint(mock_project)
assert success is False
assert "Checkpoint failed" in message
def test_create_checkpoint_exception(self, tmp_path, monkeypatch):
"""Test checkpoint creation with exception"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
with patch("pyPhotoAlbum.autosave_manager.save_to_zip") as mock_save:
mock_save.side_effect = Exception("IO Error")
mock_project = Mock()
mock_project.name = "TestProject"
success, message = manager.create_checkpoint(mock_project)
assert success is False
assert "Checkpoint error" in message
class TestSaveCheckpointMetadata:
"""Tests for _save_checkpoint_metadata method"""
def test_save_metadata(self, tmp_path, monkeypatch):
"""Test saving checkpoint metadata"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
mock_project = Mock()
mock_project.name = "TestProject"
mock_project.file_path = "/path/to/original.ppz"
checkpoint_path = checkpoint_dir / "checkpoint_TestProject_20240115_103045.ppz"
checkpoint_path.touch()
manager._save_checkpoint_metadata(mock_project, checkpoint_path)
metadata_path = checkpoint_path.with_suffix(".json")
assert metadata_path.exists()
with open(metadata_path, "r") as f:
metadata = json.load(f)
assert metadata["project_name"] == "TestProject"
assert metadata["original_path"] == "/path/to/original.ppz"
assert "timestamp" in metadata
class TestListCheckpoints:
"""Tests for list_checkpoints method"""
def test_list_checkpoints_empty(self, tmp_path, monkeypatch):
"""Test listing checkpoints when none exist"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
checkpoints = manager.list_checkpoints()
assert checkpoints == []
def test_list_checkpoints_with_files(self, tmp_path, monkeypatch):
"""Test listing checkpoints with existing files"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create some checkpoint files
cp1 = checkpoint_dir / "checkpoint_Project1_20240115_100000.ppz"
cp2 = checkpoint_dir / "checkpoint_Project2_20240115_110000.ppz"
cp1.touch()
cp2.touch()
# Create metadata for first checkpoint
metadata1 = {"project_name": "Project1", "timestamp": "2024-01-15T10:00:00"}
with open(cp1.with_suffix(".json"), "w") as f:
json.dump(metadata1, f)
manager = AutosaveManager()
checkpoints = manager.list_checkpoints()
assert len(checkpoints) == 2
def test_list_checkpoints_filter_by_project(self, tmp_path, monkeypatch):
"""Test listing checkpoints filtered by project name"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoint files with metadata
cp1 = checkpoint_dir / "checkpoint_Project1_20240115_100000.ppz"
cp2 = checkpoint_dir / "checkpoint_Project2_20240115_110000.ppz"
cp1.touch()
cp2.touch()
metadata1 = {"project_name": "Project1", "timestamp": "2024-01-15T10:00:00"}
metadata2 = {"project_name": "Project2", "timestamp": "2024-01-15T11:00:00"}
with open(cp1.with_suffix(".json"), "w") as f:
json.dump(metadata1, f)
with open(cp2.with_suffix(".json"), "w") as f:
json.dump(metadata2, f)
manager = AutosaveManager()
checkpoints = manager.list_checkpoints("Project1")
assert len(checkpoints) == 1
assert checkpoints[0][1]["project_name"] == "Project1"
def test_list_checkpoints_sorted_by_timestamp(self, tmp_path, monkeypatch):
"""Test that checkpoints are sorted by timestamp (newest first)"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoints with different timestamps
cp1 = checkpoint_dir / "checkpoint_Project_20240115_080000.ppz"
cp2 = checkpoint_dir / "checkpoint_Project_20240115_120000.ppz"
cp3 = checkpoint_dir / "checkpoint_Project_20240115_100000.ppz"
cp1.touch()
cp2.touch()
cp3.touch()
for cp, hour in [(cp1, "08"), (cp2, "12"), (cp3, "10")]:
metadata = {"project_name": "Project", "timestamp": f"2024-01-15T{hour}:00:00"}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
checkpoints = manager.list_checkpoints()
# Should be sorted newest first: 12:00, 10:00, 08:00
assert "12:00:00" in checkpoints[0][1]["timestamp"]
assert "10:00:00" in checkpoints[1][1]["timestamp"]
assert "08:00:00" in checkpoints[2][1]["timestamp"]
class TestLoadCheckpoint:
"""Tests for load_checkpoint method"""
def test_load_checkpoint_success(self, tmp_path, monkeypatch):
"""Test successful checkpoint loading"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
with patch("pyPhotoAlbum.autosave_manager.load_from_zip") as mock_load:
mock_project = Mock()
mock_load.return_value = mock_project
checkpoint_path = checkpoint_dir / "checkpoint_Test.ppz"
success, result = manager.load_checkpoint(checkpoint_path)
assert success is True
assert result == mock_project
def test_load_checkpoint_failure(self, tmp_path, monkeypatch):
"""Test checkpoint loading failure"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
with patch("pyPhotoAlbum.autosave_manager.load_from_zip") as mock_load:
mock_load.side_effect = Exception("Corrupt file")
checkpoint_path = checkpoint_dir / "checkpoint_Test.ppz"
success, result = manager.load_checkpoint(checkpoint_path)
assert success is False
assert "Failed to load checkpoint" in result
class TestDeleteCheckpoint:
"""Tests for delete_checkpoint method"""
def test_delete_checkpoint_success(self, tmp_path, monkeypatch):
"""Test successful checkpoint deletion"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoint and metadata files
cp = checkpoint_dir / "checkpoint_Test.ppz"
cp.touch()
metadata = cp.with_suffix(".json")
metadata.touch()
manager = AutosaveManager()
result = manager.delete_checkpoint(cp)
assert result is True
assert not cp.exists()
assert not metadata.exists()
def test_delete_checkpoint_nonexistent(self, tmp_path, monkeypatch):
"""Test deleting nonexistent checkpoint"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
cp = checkpoint_dir / "nonexistent.ppz"
result = manager.delete_checkpoint(cp)
assert result is True # Should succeed even if file doesn't exist
class TestDeleteAllCheckpoints:
"""Tests for delete_all_checkpoints method"""
def test_delete_all_checkpoints(self, tmp_path, monkeypatch):
"""Test deleting all checkpoints"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create multiple checkpoints
for i in range(3):
cp = checkpoint_dir / f"checkpoint_Project_{i}.ppz"
cp.touch()
metadata = {"project_name": "Project", "timestamp": f"2024-01-15T{i}:00:00"}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
manager.delete_all_checkpoints()
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
assert len(remaining) == 0
def test_delete_all_checkpoints_filtered(self, tmp_path, monkeypatch):
"""Test deleting all checkpoints for specific project"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoints for different projects
for name in ["ProjectA", "ProjectB", "ProjectA"]:
cp = checkpoint_dir / f"checkpoint_{name}_{datetime.now().strftime('%Y%m%d_%H%M%S%f')}.ppz"
cp.touch()
metadata = {"project_name": name, "timestamp": datetime.now().isoformat()}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
manager.delete_all_checkpoints("ProjectA")
# Only ProjectB should remain
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
assert len(remaining) == 1
assert "ProjectB" in remaining[0].name
class TestCleanupOldCheckpoints:
"""Tests for cleanup_old_checkpoints method"""
def test_cleanup_old_checkpoints_by_age(self, tmp_path, monkeypatch):
"""Test cleanup of old checkpoints by age"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create old and new checkpoints
old_time = datetime.now() - timedelta(hours=48)
new_time = datetime.now() - timedelta(hours=1)
old_cp = checkpoint_dir / "checkpoint_Project_old.ppz"
new_cp = checkpoint_dir / "checkpoint_Project_new.ppz"
old_cp.touch()
new_cp.touch()
old_metadata = {"project_name": "Project", "timestamp": old_time.isoformat()}
new_metadata = {"project_name": "Project", "timestamp": new_time.isoformat()}
with open(old_cp.with_suffix(".json"), "w") as f:
json.dump(old_metadata, f)
with open(new_cp.with_suffix(".json"), "w") as f:
json.dump(new_metadata, f)
manager = AutosaveManager()
manager.cleanup_old_checkpoints(max_age_hours=24)
# Only new checkpoint should remain
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
assert len(remaining) == 1
assert "new" in remaining[0].name
def test_cleanup_old_checkpoints_by_count(self, tmp_path, monkeypatch):
"""Test cleanup of checkpoints by count"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create many recent checkpoints
for i in range(5):
timestamp = datetime.now() - timedelta(hours=i)
cp = checkpoint_dir / f"checkpoint_Project_{i:02d}.ppz"
cp.touch()
metadata = {"project_name": "Project", "timestamp": timestamp.isoformat()}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
manager.cleanup_old_checkpoints(max_age_hours=24 * 7, max_count=3)
# Should only keep 3 most recent
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
assert len(remaining) == 3
class TestHasCheckpoints:
"""Tests for has_checkpoints method"""
def test_has_checkpoints_true(self, tmp_path, monkeypatch):
"""Test has_checkpoints returns True when checkpoints exist"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
cp = checkpoint_dir / "checkpoint_Test.ppz"
cp.touch()
manager = AutosaveManager()
assert manager.has_checkpoints() is True
def test_has_checkpoints_false(self, tmp_path, monkeypatch):
"""Test has_checkpoints returns False when no checkpoints"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
assert manager.has_checkpoints() is False
class TestGetLatestCheckpoint:
"""Tests for get_latest_checkpoint method"""
def test_get_latest_checkpoint(self, tmp_path, monkeypatch):
"""Test getting the latest checkpoint"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoints with different timestamps
for hour in [8, 10, 12]:
cp = checkpoint_dir / f"checkpoint_Project_{hour:02d}.ppz"
cp.touch()
metadata = {"project_name": "Project", "timestamp": f"2024-01-15T{hour:02d}:00:00"}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
result = manager.get_latest_checkpoint()
assert result is not None
assert "12:00:00" in result[1]["timestamp"]
def test_get_latest_checkpoint_none(self, tmp_path, monkeypatch):
"""Test getting latest checkpoint when none exist"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
result = manager.get_latest_checkpoint()
assert result is None
def test_get_latest_checkpoint_filtered(self, tmp_path, monkeypatch):
"""Test getting latest checkpoint for specific project"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoints for different projects
for name, hour in [("ProjectA", 10), ("ProjectB", 12), ("ProjectA", 8)]:
cp = checkpoint_dir / f"checkpoint_{name}_{hour:02d}.ppz"
cp.touch()
metadata = {"project_name": name, "timestamp": f"2024-01-15T{hour:02d}:00:00"}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
result = manager.get_latest_checkpoint("ProjectA")
assert result is not None
assert result[1]["project_name"] == "ProjectA"
assert "10:00:00" in result[1]["timestamp"] # Latest for ProjectA

View File

@ -249,7 +249,7 @@ class TestDialogMethods:
qtbot.addWidget(window)
mock_critical = Mock()
monkeypatch.setattr(QMessageBox, 'critical', mock_critical)
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
window.show_error("Error Title", "Error message")
@ -260,7 +260,7 @@ class TestDialogMethods:
qtbot.addWidget(window)
mock_warning = Mock()
monkeypatch.setattr(QMessageBox, 'warning', mock_warning)
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
window.show_warning("Warning Title", "Warning message")
@ -271,7 +271,7 @@ class TestDialogMethods:
qtbot.addWidget(window)
mock_info = Mock()
monkeypatch.setattr(QMessageBox, 'information', mock_info)
monkeypatch.setattr(QMessageBox, "information", mock_info)
window.show_info("Info Title", "Info message")
@ -309,7 +309,7 @@ class TestRequirePage:
window._gl_widget = Mock()
mock_warning = Mock()
monkeypatch.setattr(QMessageBox, 'warning', mock_warning)
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
result = window.require_page(show_warning=True)
@ -366,7 +366,7 @@ class TestRequireSelection:
window._gl_widget = gl_widget
mock_info = Mock()
monkeypatch.setattr(QMessageBox, 'information', mock_info)
monkeypatch.setattr(QMessageBox, "information", mock_info)
result = window.require_selection(min_count=1, show_warning=True)
@ -386,7 +386,7 @@ class TestRequireSelection:
window._gl_widget = gl_widget
mock_info = Mock()
monkeypatch.setattr(QMessageBox, 'information', mock_info)
monkeypatch.setattr(QMessageBox, "information", mock_info)
result = window.require_selection(min_count=3, show_warning=True)

View File

@ -16,7 +16,7 @@ from pyPhotoAlbum.commands import (
ChangeZOrderCommand,
StateChangeCommand,
CommandHistory,
_normalize_asset_path
_normalize_asset_path,
)
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
from pyPhotoAlbum.page_layout import PageLayout
@ -107,9 +107,9 @@ class TestAddElementCommand:
data = cmd.serialize()
assert data['type'] == 'add_element'
assert 'element' in data
assert data['executed'] is True
assert data["type"] == "add_element"
assert "element" in data
assert data["executed"] is True
def test_add_element_with_asset_manager(self):
"""Test add element with asset manager reference"""
@ -164,8 +164,8 @@ class TestDeleteElementCommand:
cmd = DeleteElementCommand(layout, element)
data = cmd.serialize()
assert data['type'] == 'delete_element'
assert 'element' in data
assert data["type"] == "delete_element"
assert "element" in data
class TestMoveElementCommand:
@ -198,9 +198,9 @@ class TestMoveElementCommand:
cmd = MoveElementCommand(element, old_position=(100, 100), new_position=(200, 200))
data = cmd.serialize()
assert data['type'] == 'move_element'
assert data['old_position'] == (100, 100)
assert data['new_position'] == (200, 200)
assert data["type"] == "move_element"
assert data["old_position"] == (100, 100)
assert data["new_position"] == (200, 200)
class TestResizeElementCommand:
@ -211,11 +211,7 @@ class TestResizeElementCommand:
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
cmd = ResizeElementCommand(
element,
old_position=(100, 100),
old_size=(200, 150),
new_position=(100, 100),
new_size=(300, 225)
element, old_position=(100, 100), old_size=(200, 150), new_position=(100, 100), new_size=(300, 225)
)
cmd.execute()
@ -226,11 +222,7 @@ class TestResizeElementCommand:
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
cmd = ResizeElementCommand(
element,
old_position=(100, 100),
old_size=(200, 150),
new_position=(100, 100),
new_size=(300, 225)
element, old_position=(100, 100), old_size=(200, 150), new_position=(100, 100), new_size=(300, 225)
)
cmd.execute()
@ -243,11 +235,7 @@ class TestResizeElementCommand:
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
cmd = ResizeElementCommand(
element,
old_position=(100, 100),
old_size=(200, 150),
new_position=(90, 90),
new_size=(220, 165)
element, old_position=(100, 100), old_size=(200, 150), new_position=(90, 90), new_size=(220, 165)
)
cmd.execute()
@ -298,9 +286,9 @@ class TestRotateElementCommand:
cmd = RotateElementCommand(element, old_rotation=0, new_rotation=45)
data = cmd.serialize()
assert data['type'] == 'rotate_element'
assert data['old_rotation'] == 0
assert data['new_rotation'] == 45
assert data["type"] == "rotate_element"
assert data["old_rotation"] == 0
assert data["new_rotation"] == 45
class TestAdjustImageCropCommand:
@ -310,32 +298,25 @@ class TestAdjustImageCropCommand:
"""Test adjusting image crop"""
element = ImageData(
image_path="/test.jpg",
x=100, y=100,
width=200, height=150,
crop_info={'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0}
x=100,
y=100,
width=200,
height=150,
crop_info={"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0},
)
new_crop = {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8}
cmd = AdjustImageCropCommand(
element,
old_crop_info=element.crop_info.copy(),
new_crop_info=new_crop
)
new_crop = {"x": 0.1, "y": 0.1, "width": 0.8, "height": 0.8}
cmd = AdjustImageCropCommand(element, old_crop_info=element.crop_info.copy(), new_crop_info=new_crop)
cmd.execute()
assert element.crop_info == new_crop
def test_adjust_crop_undo(self):
"""Test undoing crop adjustment"""
old_crop = {'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0}
element = ImageData(
image_path="/test.jpg",
x=100, y=100,
width=200, height=150,
crop_info=old_crop.copy()
)
old_crop = {"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0}
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150, crop_info=old_crop.copy())
new_crop = {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8}
new_crop = {"x": 0.1, "y": 0.1, "width": 0.8, "height": 0.8}
cmd = AdjustImageCropCommand(element, old_crop_info=old_crop, new_crop_info=new_crop)
cmd.execute()
@ -399,10 +380,7 @@ class TestResizeElementsCommand:
element2.size = (300, 300)
# Command expects list of (element, old_position, old_size) tuples
changes = [
(element1, (100, 100), (100, 100)),
(element2, (200, 200), (150, 150))
]
changes = [(element1, (100, 100), (100, 100)), (element2, (200, 200), (150, 150))]
cmd = ResizeElementsCommand(changes)
cmd.execute()
@ -421,10 +399,7 @@ class TestResizeElementsCommand:
element2.size = (300, 300)
# Command expects list of (element, old_position, old_size) tuples
changes = [
(element1, (100, 100), (100, 100)),
(element2, (200, 200), (150, 150))
]
changes = [(element1, (100, 100), (100, 100)), (element2, (200, 200), (150, 150))]
cmd = ResizeElementsCommand(changes)
cmd.execute()
@ -476,81 +451,68 @@ class TestStateChangeCommand:
def test_state_change_undo(self):
"""Test undoing state change"""
element = TextBoxData(
text_content="Old Text",
x=100, y=100,
width=200, height=100
)
element = TextBoxData(text_content="Old Text", x=100, y=100, width=200, height=100)
# Define restore function
def restore_state(state):
element.text_content = state['text_content']
element.text_content = state["text_content"]
old_state = {'text_content': 'Old Text'}
new_state = {'text_content': 'New Text'}
old_state = {"text_content": "Old Text"}
new_state = {"text_content": "New Text"}
# Apply new state first
element.text_content = 'New Text'
element.text_content = "New Text"
cmd = StateChangeCommand(
description="Change text",
restore_func=restore_state,
before_state=old_state,
after_state=new_state
description="Change text", restore_func=restore_state, before_state=old_state, after_state=new_state
)
# Undo should restore old state
cmd.undo()
assert element.text_content == 'Old Text'
assert element.text_content == "Old Text"
def test_state_change_redo(self):
"""Test redoing state change"""
element = TextBoxData(
text_content="Old Text",
x=100, y=100,
width=200, height=100
)
element = TextBoxData(text_content="Old Text", x=100, y=100, width=200, height=100)
# Define restore function
def restore_state(state):
element.text_content = state['text_content']
element.text_content = state["text_content"]
old_state = {'text_content': 'Old Text'}
new_state = {'text_content': 'New Text'}
old_state = {"text_content": "Old Text"}
new_state = {"text_content": "New Text"}
# Apply new state first
element.text_content = 'New Text'
element.text_content = "New Text"
cmd = StateChangeCommand(
description="Change text",
restore_func=restore_state,
before_state=old_state,
after_state=new_state
description="Change text", restore_func=restore_state, before_state=old_state, after_state=new_state
)
# Undo then redo
cmd.undo()
assert element.text_content == 'Old Text'
assert element.text_content == "Old Text"
cmd.redo()
assert element.text_content == 'New Text'
assert element.text_content == "New Text"
def test_state_change_serialization(self):
"""Test serializing state change command"""
def restore_func(state):
pass
cmd = StateChangeCommand(
description="Test operation",
restore_func=restore_func,
before_state={'test': 'before'},
after_state={'test': 'after'}
before_state={"test": "before"},
after_state={"test": "after"},
)
data = cmd.serialize()
assert data['type'] == 'state_change'
assert data['description'] == 'Test operation'
assert data["type"] == "state_change"
assert data["description"] == "Test operation"
class TestCommandHistory:
@ -656,7 +618,7 @@ class TestCommandHistory:
layout = PageLayout(width=210, height=297)
for i in range(5):
element = ImageData(image_path=f"/test{i}.jpg", x=i*10, y=i*10, width=100, height=100)
element = ImageData(image_path=f"/test{i}.jpg", x=i * 10, y=i * 10, width=100, height=100)
history.execute(AddElementCommand(layout, element))
# Should only have 3 commands in history (max_history)
@ -678,8 +640,8 @@ class TestCommandHistory:
# Serialize
data = history.serialize()
assert len(data['undo_stack']) == 1
assert data['undo_stack'][0]['type'] == 'add_element'
assert len(data["undo_stack"]) == 1
assert data["undo_stack"][0]["type"] == "add_element"
# Create mock project for deserialization
mock_project = Mock()
@ -734,7 +696,7 @@ class TestCommandHistory:
# Manually build serialized history data
data = {
'undo_stack': [
"undo_stack": [
cmd1.serialize(),
cmd2.serialize(),
cmd3.serialize(),
@ -745,8 +707,8 @@ class TestCommandHistory:
cmd8.serialize(),
cmd9.serialize(),
],
'redo_stack': [],
'max_history': 100
"redo_stack": [],
"max_history": 100,
}
# Create mock project
@ -758,15 +720,15 @@ class TestCommandHistory:
new_history.deserialize(data, mock_project)
assert len(new_history.undo_stack) == 9
assert new_history.undo_stack[0].__class__.__name__ == 'AddElementCommand'
assert new_history.undo_stack[1].__class__.__name__ == 'DeleteElementCommand'
assert new_history.undo_stack[2].__class__.__name__ == 'MoveElementCommand'
assert new_history.undo_stack[3].__class__.__name__ == 'ResizeElementCommand'
assert new_history.undo_stack[4].__class__.__name__ == 'RotateElementCommand'
assert new_history.undo_stack[5].__class__.__name__ == 'AdjustImageCropCommand'
assert new_history.undo_stack[6].__class__.__name__ == 'AlignElementsCommand'
assert new_history.undo_stack[7].__class__.__name__ == 'ResizeElementsCommand'
assert new_history.undo_stack[8].__class__.__name__ == 'ChangeZOrderCommand'
assert new_history.undo_stack[0].__class__.__name__ == "AddElementCommand"
assert new_history.undo_stack[1].__class__.__name__ == "DeleteElementCommand"
assert new_history.undo_stack[2].__class__.__name__ == "MoveElementCommand"
assert new_history.undo_stack[3].__class__.__name__ == "ResizeElementCommand"
assert new_history.undo_stack[4].__class__.__name__ == "RotateElementCommand"
assert new_history.undo_stack[5].__class__.__name__ == "AdjustImageCropCommand"
assert new_history.undo_stack[6].__class__.__name__ == "AlignElementsCommand"
assert new_history.undo_stack[7].__class__.__name__ == "ResizeElementsCommand"
assert new_history.undo_stack[8].__class__.__name__ == "ChangeZOrderCommand"
def test_history_deserialize_unknown_command_type(self):
"""Test deserializing unknown command type returns None and continues"""
@ -774,12 +736,12 @@ class TestCommandHistory:
mock_project = Mock()
data = {
'undo_stack': [
{'type': 'unknown_command', 'data': 'test'},
{'type': 'add_element', 'element': ImageData().serialize(), 'executed': True}
"undo_stack": [
{"type": "unknown_command", "data": "test"},
{"type": "add_element", "element": ImageData().serialize(), "executed": True},
],
'redo_stack': [],
'max_history': 100
"redo_stack": [],
"max_history": 100,
}
# Should not raise exception, just skip unknown command
@ -787,7 +749,7 @@ class TestCommandHistory:
# Should only have the valid command
assert len(history.undo_stack) == 1
assert history.undo_stack[0].__class__.__name__ == 'AddElementCommand'
assert history.undo_stack[0].__class__.__name__ == "AddElementCommand"
def test_history_deserialize_malformed_command(self):
"""Test deserializing malformed command handles exception gracefully"""
@ -795,13 +757,17 @@ class TestCommandHistory:
mock_project = Mock()
data = {
'undo_stack': [
{'type': 'add_element'}, # Missing required 'element' field
{'type': 'move_element', 'element': ImageData().serialize(),
'old_position': (0, 0), 'new_position': (10, 10)}
"undo_stack": [
{"type": "add_element"}, # Missing required 'element' field
{
"type": "move_element",
"element": ImageData().serialize(),
"old_position": (0, 0),
"new_position": (10, 10),
},
],
'redo_stack': [],
'max_history': 100
"redo_stack": [],
"max_history": 100,
}
# Should not raise exception, just skip malformed command
@ -809,7 +775,7 @@ class TestCommandHistory:
# Should only have the valid command
assert len(history.undo_stack) == 1
assert history.undo_stack[0].__class__.__name__ == 'MoveElementCommand'
assert history.undo_stack[0].__class__.__name__ == "MoveElementCommand"
def test_history_serialize_deserialize_with_redo_stack(self):
"""Test serializing and deserializing with items in redo stack"""
@ -826,8 +792,8 @@ class TestCommandHistory:
# Serialize
data = history.serialize()
assert len(data['undo_stack']) == 1
assert len(data['redo_stack']) == 1
assert len(data["undo_stack"]) == 1
assert len(data["redo_stack"]) == 1
# Deserialize
mock_project = Mock()

View File

@ -56,7 +56,7 @@ class TestGetSelectedElementsList:
class TestDistributeHorizontally:
"""Test distribute_horizontally method"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_distribute_horizontally_success(self, mock_manager, qtbot):
window = TestDistributionWindow()
qtbot.addWidget(window)
@ -70,7 +70,7 @@ class TestDistributeHorizontally:
mock_manager.distribute_horizontally.return_value = [
(element1, (0, 0)),
(element2, (150, 0)),
(element3, (500, 0))
(element3, (500, 0)),
]
window.distribute_horizontally()
@ -98,7 +98,7 @@ class TestDistributeHorizontally:
class TestDistributeVertically:
"""Test distribute_vertically method"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_distribute_vertically_success(self, mock_manager, qtbot):
window = TestDistributionWindow()
qtbot.addWidget(window)
@ -112,7 +112,7 @@ class TestDistributeVertically:
mock_manager.distribute_vertically.return_value = [
(element1, (0, 0)),
(element2, (0, 150)),
(element3, (0, 500))
(element3, (0, 500)),
]
window.distribute_vertically()
@ -125,7 +125,7 @@ class TestDistributeVertically:
class TestSpaceHorizontally:
"""Test space_horizontally method"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_space_horizontally_success(self, mock_manager, qtbot):
window = TestDistributionWindow()
qtbot.addWidget(window)
@ -136,11 +136,7 @@ class TestSpaceHorizontally:
window.gl_widget.selected_elements = {element1, element2, element3}
mock_manager.space_horizontally.return_value = [
(element1, (0, 0)),
(element2, (100, 0)),
(element3, (200, 0))
]
mock_manager.space_horizontally.return_value = [(element1, (0, 0)), (element2, (100, 0)), (element3, (200, 0))]
window.space_horizontally()
@ -152,7 +148,7 @@ class TestSpaceHorizontally:
class TestSpaceVertically:
"""Test space_vertically method"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_space_vertically_success(self, mock_manager, qtbot):
window = TestDistributionWindow()
qtbot.addWidget(window)
@ -163,11 +159,7 @@ class TestSpaceVertically:
window.gl_widget.selected_elements = {element1, element2, element3}
mock_manager.space_vertically.return_value = [
(element1, (0, 0)),
(element2, (0, 100)),
(element3, (0, 200))
]
mock_manager.space_vertically.return_value = [(element1, (0, 0)), (element2, (0, 100)), (element3, (0, 200))]
window.space_vertically()
@ -178,7 +170,7 @@ class TestSpaceVertically:
class TestDistributionCommandPattern:
"""Test distribution operations with command pattern"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_distribution_creates_command(self, mock_manager, qtbot):
window = TestDistributionWindow()
qtbot.addWidget(window)
@ -192,7 +184,7 @@ class TestDistributionCommandPattern:
mock_manager.distribute_horizontally.return_value = [
(element1, (0, 0)),
(element2, (100, 0)),
(element3, (200, 0))
(element3, (200, 0)),
]
assert not window.project.history.can_undo()

View File

@ -31,7 +31,7 @@ class TestEditWindow(EditOperationsMixin, QMainWindow):
return len(self.gl_widget.selected_elements) >= min_count
def get_current_page(self):
if hasattr(self, '_current_page'):
if hasattr(self, "_current_page"):
return self._current_page
return None

View File

@ -15,6 +15,7 @@ from pyPhotoAlbum.page_layout import PageLayout
# Create test widget combining necessary mixins
class TestManipulationWidget(ElementManipulationMixin, ElementSelectionMixin, QOpenGLWidget):
"""Test widget combining manipulation and selection mixins"""
def __init__(self):
super().__init__()
self._page_renderers = []
@ -36,11 +37,7 @@ class TestElementManipulationInitialization:
assert widget.rotation_mode is False
assert widget.rotation_start_angle is None
assert widget.rotation_snap_angle == 15
assert widget.snap_state == {
'is_snapped': False,
'last_position': None,
'last_size': None
}
assert widget.snap_state == {"is_snapped": False, "last_position": None, "last_size": None}
def test_rotation_mode_is_mutable(self, qtbot):
"""Test that rotation mode can be toggled"""
@ -72,7 +69,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@ -89,7 +86,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'nw'
widget.resize_handle = "nw"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@ -106,7 +103,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'ne'
widget.resize_handle = "ne"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@ -123,7 +120,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'sw'
widget.resize_handle = "sw"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@ -140,7 +137,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=50, height=50)
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (50, 50)
@ -157,7 +154,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
# Don't set resize_start_pos or resize_start_size
original_pos = elem.position
@ -185,7 +182,7 @@ class TestResizeElementWithSnap:
elem._parent_page = page
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@ -208,7 +205,7 @@ class TestResizeElementWithSnap:
params = call_args[0][0]
assert params.dx == 50
assert params.dy == 30
assert params.resize_handle == 'se'
assert params.resize_handle == "se"
# Verify element was updated
assert elem.size == (250, 180)
@ -220,7 +217,7 @@ class TestResizeElementWithSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@ -241,7 +238,7 @@ class TestResizeElementWithSnap:
elem._parent_page = page
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (50, 50)
@ -344,20 +341,20 @@ class TestManipulationStateManagement:
widget = TestManipulationWidget()
qtbot.addWidget(widget)
assert 'is_snapped' in widget.snap_state
assert 'last_position' in widget.snap_state
assert 'last_size' in widget.snap_state
assert "is_snapped" in widget.snap_state
assert "last_position" in widget.snap_state
assert "last_size" in widget.snap_state
def test_resize_state_can_be_set(self, qtbot):
"""Test resize state variables can be set"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
widget.resize_handle = 'nw'
widget.resize_handle = "nw"
widget.resize_start_pos = (10, 20)
widget.resize_start_size = (100, 200)
assert widget.resize_handle == 'nw'
assert widget.resize_handle == "nw"
assert widget.resize_start_pos == (10, 20)
assert widget.resize_start_size == (100, 200)

View File

@ -258,6 +258,7 @@ class TestElementMaximizer:
def test_maximize_empty_elements(self):
"""Test maximize with empty element list."""
from pyPhotoAlbum.alignment import AlignmentManager
result = AlignmentManager.maximize_pattern([], (200.0, 200.0))
assert result == []

View File

@ -43,11 +43,11 @@ class TestElementWindow(ElementOperationsMixin, AssetPathMixin, QMainWindow):
def require_page(self):
"""Track require_page calls"""
self._require_page_called = True
return self._current_page is not None if hasattr(self, '_current_page') else False
return self._current_page is not None if hasattr(self, "_current_page") else False
def get_current_page(self):
"""Return mock current page"""
if hasattr(self, '_current_page'):
if hasattr(self, "_current_page"):
return self._current_page
return None
@ -71,8 +71,8 @@ class TestElementWindow(ElementOperationsMixin, AssetPathMixin, QMainWindow):
class TestAddImage:
"""Test add_image method"""
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_success(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test successfully adding an image"""
window = TestElementWindow()
@ -106,7 +106,7 @@ class TestAddImage:
assert window._update_view_called
assert "added image" in window._status_message.lower()
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
def test_add_image_cancelled(self, mock_file_dialog, qtbot):
"""Test cancelling image selection"""
window = TestElementWindow()
@ -139,8 +139,8 @@ class TestAddImage:
assert window._require_page_called
assert not window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_scales_large_image(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test that large images are scaled down"""
window = TestElementWindow()
@ -164,8 +164,8 @@ class TestAddImage:
# Image should be added (scaled down by get_image_dimensions)
assert window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_fallback_dimensions(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test fallback dimensions when get_image_dimensions returns None"""
window = TestElementWindow()
@ -294,8 +294,8 @@ class TestAddPlaceholder:
class TestElementOperationsIntegration:
"""Test integration between element operations"""
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_multiple_elements(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test adding multiple different element types"""
window = TestElementWindow()
@ -326,8 +326,8 @@ class TestElementOperationsIntegration:
# Should have added all three elements
assert window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_with_undo(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test that adding image can be undone"""
window = TestElementWindow()

View File

@ -23,19 +23,19 @@ def mock_page_renderer():
# Mock coordinate conversion methods
def page_to_screen(x, y):
return (renderer.screen_x + x * renderer.zoom,
renderer.screen_y + y * renderer.zoom)
return (renderer.screen_x + x * renderer.zoom, renderer.screen_y + y * renderer.zoom)
def screen_to_page(x, y):
return ((x - renderer.screen_x) / renderer.zoom,
(y - renderer.screen_y) / renderer.zoom)
return ((x - renderer.screen_x) / renderer.zoom, (y - renderer.screen_y) / renderer.zoom)
def is_point_in_page(x, y):
# Simple bounds check (assume 210mm x 297mm page at 96 DPI)
page_width_px = 210 * 96 / 25.4
page_height_px = 297 * 96 / 25.4
return (renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom and
renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom)
return (
renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom
and renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom
)
renderer.page_to_screen = page_to_screen
renderer.screen_to_page = screen_to_page
@ -47,6 +47,7 @@ def mock_page_renderer():
# Create a minimal test widget class
class TestSelectionWidget(ElementSelectionMixin, QOpenGLWidget):
"""Test widget combining ElementSelectionMixin with QOpenGLWidget"""
def __init__(self):
super().__init__()
self._page_renderers = []
@ -60,7 +61,7 @@ class TestElementSelectionInitialization:
widget = TestSelectionWidget()
qtbot.addWidget(widget)
assert hasattr(widget, 'selected_elements')
assert hasattr(widget, "selected_elements")
assert isinstance(widget.selected_elements, set)
assert len(widget.selected_elements) == 0
@ -172,8 +173,8 @@ class TestGetElementAt:
assert result is not None
assert result == elem
assert hasattr(result, '_page_renderer')
assert hasattr(result, '_parent_page')
assert hasattr(result, "_page_renderer")
assert hasattr(result, "_parent_page")
def test_get_element_at_finds_topmost_element(self, qtbot, mock_page_renderer):
"""Test _get_element_at returns topmost element when overlapping"""
@ -246,8 +247,8 @@ class TestGetElementAt:
# Screen coords: (50 + 200, 50 + 175) = (250, 225)
result = widget._get_element_at(250, 225)
assert result == elem
assert hasattr(result, '_page_renderer')
assert hasattr(result, '_parent_page')
assert hasattr(result, "_page_renderer")
assert hasattr(result, "_parent_page")
def test_get_element_at_rotated_element_outside(self, qtbot, mock_page_renderer):
"""Test _get_element_at correctly rejects clicks outside rotated element"""
@ -288,8 +289,8 @@ class TestGetElementAt:
# Should be able to select the element even though it's off the page
assert result is not None
assert result == elem
assert hasattr(result, '_page_renderer')
assert hasattr(result, '_parent_page')
assert hasattr(result, "_page_renderer")
assert hasattr(result, "_parent_page")
class TestGetResizeHandleAt:
@ -330,6 +331,7 @@ class TestGetResizeHandleAt:
# Mock window with project
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@ -351,6 +353,7 @@ class TestGetResizeHandleAt:
# Mock window with project
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@ -359,7 +362,7 @@ class TestGetResizeHandleAt:
# Click on NW handle (screen: 50 + 100 = 150, 50 + 100 = 150)
result = widget._get_resize_handle_at(150, 150)
assert result == 'nw'
assert result == "nw"
def test_get_resize_handle_detects_all_corners(self, qtbot, mock_page_renderer):
"""Test _get_resize_handle_at detects all four corners"""
@ -373,6 +376,7 @@ class TestGetResizeHandleAt:
# Mock window
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@ -380,16 +384,16 @@ class TestGetResizeHandleAt:
widget.window = Mock(return_value=mock_window)
# NW corner (screen: 50 + 100 = 150, 50 + 100 = 150)
assert widget._get_resize_handle_at(150, 150) == 'nw'
assert widget._get_resize_handle_at(150, 150) == "nw"
# NE corner (screen: 50 + 300 = 350, 50 + 100 = 150)
assert widget._get_resize_handle_at(350, 150) == 'ne'
assert widget._get_resize_handle_at(350, 150) == "ne"
# SW corner (screen: 50 + 100 = 150, 50 + 250 = 300)
assert widget._get_resize_handle_at(150, 300) == 'sw'
assert widget._get_resize_handle_at(150, 300) == "sw"
# SE corner (screen: 50 + 300 = 350, 50 + 250 = 300)
assert widget._get_resize_handle_at(350, 300) == 'se'
assert widget._get_resize_handle_at(350, 300) == "se"
def test_get_resize_handle_returns_none_for_center(self, qtbot, mock_page_renderer):
"""Test _get_resize_handle_at returns None for element center"""
@ -403,6 +407,7 @@ class TestGetResizeHandleAt:
# Mock window
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@ -426,6 +431,7 @@ class TestGetResizeHandleAt:
# Mock window
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@ -436,7 +442,7 @@ class TestGetResizeHandleAt:
# For rotated element, the handle positions are transformed
result = widget._get_resize_handle_at(150, 150)
# Should detect a handle (exact handle depends on rotation transform)
assert result is None or result in ['nw', 'ne', 'sw', 'se']
assert result is None or result in ["nw", "ne", "sw", "se"]
def test_get_resize_handle_rotated_90_degrees(self, qtbot, mock_page_renderer):
"""Test _get_resize_handle_at handles 90-degree rotated elements"""
@ -451,6 +457,7 @@ class TestGetResizeHandleAt:
# Mock window
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@ -460,7 +467,7 @@ class TestGetResizeHandleAt:
# Test clicking at various positions - rotation code should handle them
# Just verify the method runs without crashing
result = widget._get_resize_handle_at(200, 200)
assert result is None or result in ['nw', 'ne', 'sw', 'se']
assert result is None or result in ["nw", "ne", "sw", "se"]
class TestMultiSelect:

View File

@ -21,10 +21,7 @@ def test_embed_template_in_project():
# Create a simple template
template = Template(name="Test Template", description="A test template")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
# Embed the template
@ -46,10 +43,7 @@ def test_load_embedded_template():
# Create and embed a template
template = Template(name="Test Template", description="A test template")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
template_manager.embed_template(template)
@ -112,10 +106,7 @@ def test_serialize_project_with_embedded_templates():
# Create and embed a template
template = Template(name="Test Template", description="A test template")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
template_manager.embed_template(template)
@ -135,10 +126,7 @@ def test_deserialize_project_with_embedded_templates():
template_manager = TemplateManager(project=project)
template = Template(name="Test Template", description="A test template")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
template_manager.embed_template(template)
@ -172,10 +160,7 @@ def test_auto_embed_on_apply():
# Create a template (not embedded yet)
template = Template(name="Auto Embed Test", description="Should auto-embed")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
# Apply template with auto_embed=True (default)
@ -195,10 +180,7 @@ def test_auto_embed_on_create_page():
# Create a template (not embedded yet)
template = Template(name="Auto Embed Page Test", description="Should auto-embed")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
# Create page from template with auto_embed=True (default)
@ -238,10 +220,7 @@ def test_embedded_template_with_text():
# Create a template with text
template = Template(name="Text Template")
textbox = TextBoxData(
text_content="Sample Text",
x=10, y=10, width=200, height=50
)
textbox = TextBoxData(text_content="Sample Text", x=10, y=10, width=200, height=50)
template.add_element(textbox)
# Embed and reload
@ -261,14 +240,8 @@ def test_roundtrip_serialization():
# Create a template
template = Template(name="Roundtrip Template", page_size_mm=(200, 300))
placeholder1 = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=80, height=80
)
placeholder2 = PlaceholderData(
placeholder_type="image",
x=110, y=10, width=80, height=80
)
placeholder1 = PlaceholderData(placeholder_type="image", x=10, y=10, width=80, height=80)
placeholder2 = PlaceholderData(placeholder_type="image", x=110, y=10, width=80, height=80)
template.add_element(placeholder1)
template.add_element(placeholder2)
@ -296,8 +269,6 @@ def test_roundtrip_serialization():
# Create another page from the loaded template
new_page = new_template_manager.create_page_from_template(
loaded_template,
page_number=2,
auto_embed=False # Don't embed again
loaded_template, page_number=2, auto_embed=False # Don't embed again
)
assert len(new_page.layout.elements) == 2

View File

@ -21,13 +21,13 @@ class TestGLWidgetInitialization:
qtbot.addWidget(widget)
# Verify mixin state is initialized
assert hasattr(widget, 'zoom_level')
assert hasattr(widget, 'pan_offset')
assert hasattr(widget, 'selected_elements')
assert hasattr(widget, 'drag_start_pos')
assert hasattr(widget, 'is_dragging')
assert hasattr(widget, 'is_panning')
assert hasattr(widget, 'rotation_mode')
assert hasattr(widget, "zoom_level")
assert hasattr(widget, "pan_offset")
assert hasattr(widget, "selected_elements")
assert hasattr(widget, "drag_start_pos")
assert hasattr(widget, "is_dragging")
assert hasattr(widget, "is_panning")
assert hasattr(widget, "rotation_mode")
def test_gl_widget_accepts_drops(self, qtbot):
"""Test GLWidget is configured to accept drops"""
@ -105,7 +105,7 @@ class TestGLWidgetMixinIntegration:
QPointF(75, 75),
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier
Qt.KeyboardModifier.NoModifier,
)
widget.mousePressEvent(event)
@ -125,7 +125,7 @@ class TestGLWidgetMixinIntegration:
# Begin operation (should be tracked for undo)
widget._begin_move(element)
assert widget._interaction_state.element is not None
assert widget._interaction_state.interaction_type == 'move'
assert widget._interaction_state.interaction_type == "move"
assert widget._interaction_state.position == (100, 100)
# End operation
@ -152,11 +152,8 @@ class TestGLWidgetKeyEvents:
# Create key press event for Escape
from PyQt6.QtGui import QKeyEvent
event = QKeyEvent(
QKeyEvent.Type.KeyPress,
Qt.Key.Key_Escape,
Qt.KeyboardModifier.NoModifier
)
event = QKeyEvent(QKeyEvent.Type.KeyPress, Qt.Key.Key_Escape, Qt.KeyboardModifier.NoModifier)
widget.keyPressEvent(event)
@ -186,11 +183,8 @@ class TestGLWidgetKeyEvents:
# Create key press event for Tab
from PyQt6.QtGui import QKeyEvent
event = QKeyEvent(
QKeyEvent.Type.KeyPress,
Qt.Key.Key_Tab,
Qt.KeyboardModifier.NoModifier
)
event = QKeyEvent(QKeyEvent.Type.KeyPress, Qt.Key.Key_Tab, Qt.KeyboardModifier.NoModifier)
widget.keyPressEvent(event)
@ -220,11 +214,8 @@ class TestGLWidgetKeyEvents:
# Create key press event for Delete
from PyQt6.QtGui import QKeyEvent
event = QKeyEvent(
QKeyEvent.Type.KeyPress,
Qt.Key.Key_Delete,
Qt.KeyboardModifier.NoModifier
)
event = QKeyEvent(QKeyEvent.Type.KeyPress, Qt.Key.Key_Delete, Qt.KeyboardModifier.NoModifier)
widget.keyPressEvent(event)
@ -257,7 +248,7 @@ class TestGLWidgetWithProject:
# Verify we can access project through widget
main_window = widget.window()
assert hasattr(main_window, 'project')
assert hasattr(main_window, "project")
assert main_window.project.name == "Test Project"
assert len(main_window.project.pages) == 1
assert len(main_window.project.pages[0].layout.elements) == 1
@ -321,6 +312,7 @@ class TestGLWidgetOpenGL:
# Should have NoPartialUpdate set
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
assert widget.updateBehavior() == QOpenGLWidget.UpdateBehavior.NoPartialUpdate

View File

@ -14,6 +14,7 @@ from pyPhotoAlbum.models import ImageData, PlaceholderData
# Create test widget combining necessary mixins
class TestImagePanWidget(ImagePanMixin, ElementSelectionMixin, ViewportMixin, QOpenGLWidget):
"""Test widget combining image pan, selection, and viewport mixins"""
def __init__(self):
super().__init__()
self.drag_start_pos = None

View File

@ -8,7 +8,7 @@ from pyPhotoAlbum.mixins.interaction_command_builders import (
MoveCommandBuilder,
ResizeCommandBuilder,
RotateCommandBuilder,
ImagePanCommandBuilder
ImagePanCommandBuilder,
)
from pyPhotoAlbum.mixins.interaction_validators import InteractionChangeDetector
@ -22,7 +22,7 @@ class TestMoveCommandBuilder:
element = Mock()
element.position = (10.0, 10.0)
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
assert builder.can_build(element, start_state)
@ -32,7 +32,7 @@ class TestMoveCommandBuilder:
element = Mock()
element.position = (0.05, 0.05)
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
assert not builder.can_build(element, start_state)
@ -52,7 +52,7 @@ class TestMoveCommandBuilder:
element = Mock()
element.position = (10.0, 10.0)
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
command = builder.build(element, start_state)
@ -65,7 +65,7 @@ class TestMoveCommandBuilder:
element = Mock()
element.position = (0.05, 0.05)
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
command = builder.build(element, start_state)
@ -82,10 +82,7 @@ class TestResizeCommandBuilder:
element.position = (0.0, 0.0)
element.size = (200.0, 200.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
assert builder.can_build(element, start_state)
@ -96,10 +93,7 @@ class TestResizeCommandBuilder:
element.position = (10.0, 10.0)
element.size = (100.0, 100.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
assert builder.can_build(element, start_state)
@ -110,10 +104,7 @@ class TestResizeCommandBuilder:
element.position = (10.0, 10.0)
element.size = (200.0, 200.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
assert builder.can_build(element, start_state)
@ -124,10 +115,7 @@ class TestResizeCommandBuilder:
element.position = (0.0, 0.0)
element.size = (100.0, 100.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
assert not builder.can_build(element, start_state)
@ -138,10 +126,7 @@ class TestResizeCommandBuilder:
element.position = (10.0, 10.0)
element.size = (200.0, 200.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
command = builder.build(element, start_state)
@ -158,7 +143,7 @@ class TestRotateCommandBuilder:
element = Mock()
element.rotation = 45.0
start_state = {'rotation': 0.0}
start_state = {"rotation": 0.0}
assert builder.can_build(element, start_state)
@ -168,7 +153,7 @@ class TestRotateCommandBuilder:
element = Mock()
element.rotation = 0.05
start_state = {'rotation': 0.0}
start_state = {"rotation": 0.0}
assert not builder.can_build(element, start_state)
@ -178,7 +163,7 @@ class TestRotateCommandBuilder:
element = Mock()
element.rotation = 45.0
start_state = {'rotation': 0.0}
start_state = {"rotation": 0.0}
command = builder.build(element, start_state)
@ -197,7 +182,7 @@ class TestImagePanCommandBuilder:
element = Mock(spec=ImageData)
element.crop_info = (0.1, 0.1, 0.9, 0.9)
start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)}
start_state = {"crop_info": (0.0, 0.0, 1.0, 1.0)}
assert builder.can_build(element, start_state)
@ -207,7 +192,7 @@ class TestImagePanCommandBuilder:
element = Mock()
element.crop_info = (0.1, 0.1, 0.9, 0.9)
start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)}
start_state = {"crop_info": (0.0, 0.0, 1.0, 1.0)}
assert not builder.can_build(element, start_state)
@ -219,7 +204,7 @@ class TestImagePanCommandBuilder:
element = Mock(spec=ImageData)
element.crop_info = (0.0001, 0.0001, 1.0, 1.0)
start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)}
start_state = {"crop_info": (0.0, 0.0, 1.0, 1.0)}
assert not builder.can_build(element, start_state)
@ -231,7 +216,7 @@ class TestImagePanCommandBuilder:
element = Mock(spec=ImageData)
element.crop_info = (0.1, 0.1, 0.9, 0.9)
start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)}
start_state = {"crop_info": (0.0, 0.0, 1.0, 1.0)}
command = builder.build(element, start_state)
@ -250,7 +235,7 @@ class TestCommandBuilderIntegration:
element = Mock()
element.position = (5.0, 5.0)
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
# With high threshold, this should not build
assert not builder.can_build(element, start_state)
@ -261,7 +246,7 @@ class TestCommandBuilderIntegration:
element = Mock()
element.position = (10.0, 10.0)
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
builder.build(element, start_state)

View File

@ -4,10 +4,7 @@ Unit tests for interaction command factory.
import pytest
from unittest.mock import Mock
from pyPhotoAlbum.mixins.interaction_command_factory import (
InteractionCommandFactory,
InteractionState
)
from pyPhotoAlbum.mixins.interaction_command_factory import InteractionCommandFactory, InteractionState
from pyPhotoAlbum.mixins.interaction_command_builders import CommandBuilder
@ -18,71 +15,49 @@ class TestInteractionState:
"""Test that InteractionState initializes correctly."""
element = Mock()
state = InteractionState(
element=element,
interaction_type='move',
position=(0.0, 0.0),
size=(100.0, 100.0),
rotation=0.0
element=element, interaction_type="move", position=(0.0, 0.0), size=(100.0, 100.0), rotation=0.0
)
assert state.element == element
assert state.interaction_type == 'move'
assert state.interaction_type == "move"
assert state.position == (0.0, 0.0)
assert state.size == (100.0, 100.0)
assert state.rotation == 0.0
def test_to_dict(self):
"""Test that to_dict returns correct dictionary."""
state = InteractionState(
position=(0.0, 0.0),
size=(100.0, 100.0)
)
state = InteractionState(position=(0.0, 0.0), size=(100.0, 100.0))
result = state.to_dict()
assert result == {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
assert result == {"position": (0.0, 0.0), "size": (100.0, 100.0)}
def test_to_dict_excludes_none(self):
"""Test that to_dict excludes None values."""
state = InteractionState(
position=(0.0, 0.0),
size=None
)
state = InteractionState(position=(0.0, 0.0), size=None)
result = state.to_dict()
assert 'position' in result
assert 'size' not in result
assert "position" in result
assert "size" not in result
def test_is_valid_with_required_fields(self):
"""Test that is_valid returns True when required fields are present."""
element = Mock()
state = InteractionState(
element=element,
interaction_type='move'
)
state = InteractionState(element=element, interaction_type="move")
assert state.is_valid()
def test_is_valid_without_element(self):
"""Test that is_valid returns False without element."""
state = InteractionState(
element=None,
interaction_type='move'
)
state = InteractionState(element=None, interaction_type="move")
assert not state.is_valid()
def test_is_valid_without_interaction_type(self):
"""Test that is_valid returns False without interaction_type."""
element = Mock()
state = InteractionState(
element=element,
interaction_type=None
)
state = InteractionState(element=element, interaction_type=None)
assert not state.is_valid()
@ -90,11 +65,7 @@ class TestInteractionState:
"""Test that clear resets all fields."""
element = Mock()
state = InteractionState(
element=element,
interaction_type='move',
position=(0.0, 0.0),
size=(100.0, 100.0),
rotation=0.0
element=element, interaction_type="move", position=(0.0, 0.0), size=(100.0, 100.0), rotation=0.0
)
state.clear()
@ -113,19 +84,19 @@ class TestInteractionCommandFactory:
"""Test that factory initializes with default builders."""
factory = InteractionCommandFactory()
assert factory.has_builder('move')
assert factory.has_builder('resize')
assert factory.has_builder('rotate')
assert factory.has_builder('image_pan')
assert factory.has_builder("move")
assert factory.has_builder("resize")
assert factory.has_builder("rotate")
assert factory.has_builder("image_pan")
def test_register_builder(self):
"""Test registering a custom builder."""
factory = InteractionCommandFactory()
custom_builder = Mock(spec=CommandBuilder)
factory.register_builder('custom', custom_builder)
factory.register_builder("custom", custom_builder)
assert factory.has_builder('custom')
assert factory.has_builder("custom")
def test_get_supported_types(self):
"""Test getting list of supported types."""
@ -133,10 +104,10 @@ class TestInteractionCommandFactory:
types = factory.get_supported_types()
assert 'move' in types
assert 'resize' in types
assert 'rotate' in types
assert 'image_pan' in types
assert "move" in types
assert "resize" in types
assert "rotate" in types
assert "image_pan" in types
def test_create_command_move(self):
"""Test creating a move command."""
@ -144,9 +115,9 @@ class TestInteractionCommandFactory:
element = Mock()
element.position = (10.0, 10.0)
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
command = factory.create_command('move', element, start_state)
command = factory.create_command("move", element, start_state)
assert command is not None
@ -157,12 +128,9 @@ class TestInteractionCommandFactory:
element.position = (10.0, 10.0)
element.size = (200.0, 200.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
command = factory.create_command('resize', element, start_state)
command = factory.create_command("resize", element, start_state)
assert command is not None
@ -172,9 +140,9 @@ class TestInteractionCommandFactory:
element = Mock()
element.rotation = 45.0
start_state = {'rotation': 0.0}
start_state = {"rotation": 0.0}
command = factory.create_command('rotate', element, start_state)
command = factory.create_command("rotate", element, start_state)
assert command is not None
@ -183,7 +151,7 @@ class TestInteractionCommandFactory:
factory = InteractionCommandFactory()
element = Mock()
command = factory.create_command('unknown', element, {})
command = factory.create_command("unknown", element, {})
assert command is None
captured = capsys.readouterr()
@ -195,9 +163,9 @@ class TestInteractionCommandFactory:
element = Mock()
element.position = (0.05, 0.05)
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
command = factory.create_command('move', element, start_state)
command = factory.create_command("move", element, start_state)
assert command is None
@ -211,12 +179,12 @@ class TestInteractionCommandFactory:
custom_builder.can_build.return_value = True
custom_builder.build.return_value = mock_command
factory.register_builder('custom', custom_builder)
factory.register_builder("custom", custom_builder)
element = Mock()
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
command = factory.create_command('custom', element, start_state)
command = factory.create_command("custom", element, start_state)
assert command == mock_command
custom_builder.can_build.assert_called_once()
@ -232,17 +200,9 @@ class TestInteractionStateIntegration:
element = Mock()
element.position = (10.0, 10.0)
state = InteractionState(
element=element,
interaction_type='move',
position=(0.0, 0.0)
)
state = InteractionState(element=element, interaction_type="move", position=(0.0, 0.0))
command = factory.create_command(
state.interaction_type,
state.element,
state.to_dict()
)
command = factory.create_command(state.interaction_type, state.element, state.to_dict())
assert command is not None
@ -254,7 +214,7 @@ class TestInteractionStateIntegration:
# Begin interaction
state = InteractionState()
state.element = element
state.interaction_type = 'move'
state.interaction_type = "move"
state.position = element.position
assert state.is_valid()
@ -264,11 +224,7 @@ class TestInteractionStateIntegration:
# Create command
factory = InteractionCommandFactory()
command = factory.create_command(
state.interaction_type,
state.element,
state.to_dict()
)
command = factory.create_command(state.interaction_type, state.element, state.to_dict())
assert command is not None

View File

@ -29,8 +29,8 @@ class TestUndoableInteractionInitialization:
qtbot.addWidget(widget)
# Should have initialized tracking state object
assert hasattr(widget, '_interaction_state')
assert hasattr(widget, '_command_factory')
assert hasattr(widget, "_interaction_state")
assert hasattr(widget, "_command_factory")
# State should be clear initially
assert widget._interaction_state.element is None
@ -53,7 +53,7 @@ class TestBeginMove:
widget._begin_move(element)
assert widget._interaction_state.element is element
assert widget._interaction_state.interaction_type == 'move'
assert widget._interaction_state.interaction_type == "move"
assert widget._interaction_state.position == (100, 100)
assert widget._interaction_state.size is None
assert widget._interaction_state.rotation is None
@ -87,7 +87,7 @@ class TestBeginResize:
widget._begin_resize(element)
assert widget._interaction_state.element is element
assert widget._interaction_state.interaction_type == 'resize'
assert widget._interaction_state.interaction_type == "resize"
assert widget._interaction_state.position == (100, 100)
assert widget._interaction_state.size == (200, 150)
assert widget._interaction_state.rotation is None
@ -107,7 +107,7 @@ class TestBeginRotate:
widget._begin_rotate(element)
assert widget._interaction_state.element is element
assert widget._interaction_state.interaction_type == 'rotate'
assert widget._interaction_state.interaction_type == "rotate"
assert widget._interaction_state.position is None
assert widget._interaction_state.size is None
assert widget._interaction_state.rotation == 45.0
@ -121,17 +121,12 @@ class TestBeginImagePan:
widget = TestUndoableWidget()
qtbot.addWidget(widget)
element = ImageData(
image_path="/test.jpg",
x=100, y=100,
width=200, height=150,
crop_info=(0.1, 0.2, 0.8, 0.7)
)
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150, crop_info=(0.1, 0.2, 0.8, 0.7))
widget._begin_image_pan(element)
assert widget._interaction_state.element is element
assert widget._interaction_state.interaction_type == 'image_pan'
assert widget._interaction_state.interaction_type == "image_pan"
assert widget._interaction_state.crop_info == (0.1, 0.2, 0.8, 0.7)
def test_begin_image_pan_ignores_non_image(self, qtbot):
@ -151,7 +146,7 @@ class TestBeginImagePan:
class TestEndInteraction:
"""Test _end_interaction method"""
@patch('pyPhotoAlbum.commands.MoveElementCommand')
@patch("pyPhotoAlbum.commands.MoveElementCommand")
def test_end_interaction_creates_move_command(self, mock_cmd_class, qtbot):
"""Test that ending move interaction creates MoveElementCommand"""
widget = TestUndoableWidget()
@ -177,7 +172,7 @@ class TestEndInteraction:
mock_cmd_class.assert_called_once_with(element, (100, 100), (150, 160))
assert mock_window.project.history.execute.called
@patch('pyPhotoAlbum.commands.ResizeElementCommand')
@patch("pyPhotoAlbum.commands.ResizeElementCommand")
def test_end_interaction_creates_resize_command(self, mock_cmd_class, qtbot):
"""Test that ending resize interaction creates ResizeElementCommand"""
widget = TestUndoableWidget()
@ -204,12 +199,12 @@ class TestEndInteraction:
element,
(100, 100), # old position
(200, 150), # old size
(90, 90), # new position
(250, 200) # new size
(90, 90), # new position
(250, 200), # new size
)
assert mock_window.project.history.execute.called
@patch('pyPhotoAlbum.commands.RotateElementCommand')
@patch("pyPhotoAlbum.commands.RotateElementCommand")
def test_end_interaction_creates_rotate_command(self, mock_cmd_class, qtbot):
"""Test that ending rotate interaction creates RotateElementCommand"""
widget = TestUndoableWidget()
@ -235,7 +230,7 @@ class TestEndInteraction:
mock_cmd_class.assert_called_once_with(element, 0, 90)
assert mock_window.project.history.execute.called
@patch('pyPhotoAlbum.commands.AdjustImageCropCommand')
@patch("pyPhotoAlbum.commands.AdjustImageCropCommand")
def test_end_interaction_creates_crop_command(self, mock_cmd_class, qtbot):
"""Test that ending image pan interaction creates AdjustImageCropCommand"""
widget = TestUndoableWidget()
@ -248,9 +243,11 @@ class TestEndInteraction:
element = ImageData(
image_path="/test.jpg",
x=100, y=100,
width=200, height=150,
crop_info=(0.0, 0.0, 1.0, 1.0) # Tuple format used in code
x=100,
y=100,
width=200,
height=150,
crop_info=(0.0, 0.0, 1.0, 1.0), # Tuple format used in code
)
widget._begin_image_pan(element)
@ -391,12 +388,7 @@ class TestClearInteractionState:
widget = TestUndoableWidget()
qtbot.addWidget(widget)
element = ImageData(
image_path="/test.jpg",
x=100, y=100,
width=200, height=150,
crop_info=(0.0, 0.0, 1.0, 1.0)
)
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150, crop_info=(0.0, 0.0, 1.0, 1.0))
widget._begin_image_pan(element)
# After begin_image_pan, crop_info should be stored
@ -452,10 +444,10 @@ class TestInteractionEdgeCases:
widget._begin_rotate(element)
# Should have rotate state (last call wins)
assert widget._interaction_state.interaction_type == 'rotate'
assert widget._interaction_state.interaction_type == "rotate"
assert widget._interaction_state.rotation == 0
@patch('pyPhotoAlbum.commands.ResizeElementCommand')
@patch("pyPhotoAlbum.commands.ResizeElementCommand")
def test_resize_with_only_size_change(self, mock_cmd_class, qtbot):
"""Test resize command when only size changes (position same)"""
widget = TestUndoableWidget()
@ -479,7 +471,7 @@ class TestInteractionEdgeCases:
assert mock_cmd_class.called
assert mock_window.project.history.execute.called
@patch('pyPhotoAlbum.commands.ResizeElementCommand')
@patch("pyPhotoAlbum.commands.ResizeElementCommand")
def test_resize_with_only_position_change(self, mock_cmd_class, qtbot):
"""Test resize command when only position changes (size same)"""
widget = TestUndoableWidget()

View File

@ -31,8 +31,8 @@ class TestUndoableInteractionMixinRefactored:
"""Test that mixin initializes correctly."""
widget = MockWidget()
assert hasattr(widget, '_command_factory')
assert hasattr(widget, '_interaction_state')
assert hasattr(widget, "_command_factory")
assert hasattr(widget, "_interaction_state")
def test_begin_move(self):
"""Test beginning a move interaction."""
@ -43,7 +43,7 @@ class TestUndoableInteractionMixinRefactored:
widget._begin_move(element)
assert widget._interaction_state.element == element
assert widget._interaction_state.interaction_type == 'move'
assert widget._interaction_state.interaction_type == "move"
assert widget._interaction_state.position == (0.0, 0.0)
def test_begin_resize(self):
@ -56,7 +56,7 @@ class TestUndoableInteractionMixinRefactored:
widget._begin_resize(element)
assert widget._interaction_state.element == element
assert widget._interaction_state.interaction_type == 'resize'
assert widget._interaction_state.interaction_type == "resize"
assert widget._interaction_state.position == (0.0, 0.0)
assert widget._interaction_state.size == (100.0, 100.0)
@ -69,7 +69,7 @@ class TestUndoableInteractionMixinRefactored:
widget._begin_rotate(element)
assert widget._interaction_state.element == element
assert widget._interaction_state.interaction_type == 'rotate'
assert widget._interaction_state.interaction_type == "rotate"
assert widget._interaction_state.rotation == 0.0
def test_begin_image_pan(self):
@ -83,7 +83,7 @@ class TestUndoableInteractionMixinRefactored:
widget._begin_image_pan(element)
assert widget._interaction_state.element == element
assert widget._interaction_state.interaction_type == 'image_pan'
assert widget._interaction_state.interaction_type == "image_pan"
assert widget._interaction_state.crop_info == (0.0, 0.0, 1.0, 1.0)
def test_begin_image_pan_non_image_element(self):
@ -211,7 +211,7 @@ class TestUndoableInteractionMixinRefactored:
"""Test that ending interaction without project is safe."""
widget = MockWidget()
# Remove the project attribute entirely
delattr(widget._mock_window, 'project')
delattr(widget._mock_window, "project")
element = Mock(spec=BaseLayoutElement)
element.position = (0.0, 0.0)

View File

@ -3,10 +3,7 @@ Unit tests for interaction validators and change detection.
"""
import pytest
from pyPhotoAlbum.mixins.interaction_validators import (
ChangeValidator,
InteractionChangeDetector
)
from pyPhotoAlbum.mixins.interaction_validators import ChangeValidator, InteractionChangeDetector
class TestChangeValidator:
@ -104,10 +101,10 @@ class TestInteractionChangeDetector:
change = detector.detect_position_change(old_pos, new_pos)
assert change is not None
assert change['old_position'] == old_pos
assert change['new_position'] == new_pos
assert change['delta_x'] == 5.0
assert change['delta_y'] == 3.0
assert change["old_position"] == old_pos
assert change["new_position"] == new_pos
assert change["delta_x"] == 5.0
assert change["delta_y"] == 3.0
def test_detect_position_change_insignificant(self):
"""Test that insignificant position changes return None."""
@ -128,10 +125,10 @@ class TestInteractionChangeDetector:
change = detector.detect_size_change(old_size, new_size)
assert change is not None
assert change['old_size'] == old_size
assert change['new_size'] == new_size
assert change['delta_width'] == 50.0
assert change['delta_height'] == 20.0
assert change["old_size"] == old_size
assert change["new_size"] == new_size
assert change["delta_width"] == 50.0
assert change["delta_height"] == 20.0
def test_detect_rotation_change_significant(self):
"""Test detecting significant rotation changes."""
@ -142,9 +139,9 @@ class TestInteractionChangeDetector:
change = detector.detect_rotation_change(old_rotation, new_rotation)
assert change is not None
assert change['old_rotation'] == old_rotation
assert change['new_rotation'] == new_rotation
assert change['delta_angle'] == 45.0
assert change["old_rotation"] == old_rotation
assert change["new_rotation"] == new_rotation
assert change["delta_angle"] == 45.0
def test_detect_crop_change_significant(self):
"""Test detecting significant crop changes."""
@ -155,13 +152,13 @@ class TestInteractionChangeDetector:
change = detector.detect_crop_change(old_crop, new_crop)
assert change is not None
assert change['old_crop'] == old_crop
assert change['new_crop'] == new_crop
assert change["old_crop"] == old_crop
assert change["new_crop"] == new_crop
# Use approximate comparison for floating point
assert abs(change['delta'][0] - 0.1) < 0.001
assert abs(change['delta'][1] - 0.1) < 0.001
assert abs(change['delta'][2] - (-0.1)) < 0.001
assert abs(change['delta'][3] - (-0.1)) < 0.001
assert abs(change["delta"][0] - 0.1) < 0.001
assert abs(change["delta"][1] - 0.1) < 0.001
assert abs(change["delta"][2] - (-0.1)) < 0.001
assert abs(change["delta"][3] - (-0.1)) < 0.001
def test_custom_threshold(self):
"""Test using custom threshold values."""

View File

@ -26,10 +26,7 @@ def create_base_project():
# Add a page with text
page = Page(page_number=1)
text = TextBoxData(
text_content="Original Text",
x=10, y=10, width=100, height=50
)
text = TextBoxData(text_content="Original Text", x=10, y=10, width=100, height=50)
page.layout.add_element(text)
project.add_page(page)
@ -145,7 +142,7 @@ def test_different_project_concatenation():
print("\n3. Concatenating projects...")
merged_data = concatenate_projects(data_a, data_b)
assert len(merged_data['pages']) == 2, "Should have 2 pages"
assert len(merged_data["pages"]) == 2, "Should have 2 pages"
print(f" ✓ Concatenated project has {len(merged_data['pages'])} pages")
print(f" ✓ Combined name: {merged_data['name']}")
@ -252,6 +249,7 @@ def run_all_tests():
except Exception as e:
print(f"\n❌ Test '{name}' FAILED with exception: {e}")
import traceback
traceback.print_exc()
results.append((name, False))
@ -293,20 +291,20 @@ def test_merge_helper_add_missing_pages():
data_b = project_b.serialize()
# Make them same project
data_b['project_id'] = data_a['project_id']
data_b["project_id"] = data_a["project_id"]
merge_manager = MergeManager()
merge_manager.detect_conflicts(data_a, data_b)
# Test _add_missing_pages
merged_data = data_a.copy()
merged_data['pages'] = list(data_a['pages'])
initial_page_count = len(merged_data['pages'])
merged_data["pages"] = list(data_a["pages"])
initial_page_count = len(merged_data["pages"])
merge_manager._add_missing_pages(merged_data, data_b)
# Should have added only page_b2 since page_b1 has same UUID as page_a1
assert len(merged_data['pages']) == initial_page_count + 1
assert len(merged_data["pages"]) == initial_page_count + 1
print(f" ✓ Added missing page: {len(merged_data['pages'])} total pages")
print(f"\n{'=' * 60}")
@ -332,7 +330,7 @@ def test_merge_helper_is_element_in_conflict():
element_uuid="elem-456",
our_version={},
their_version={},
description="Test conflict"
description="Test conflict",
)
merge_manager.conflicts.append(conflict)
@ -365,50 +363,24 @@ def test_merge_helper_merge_by_timestamp():
older = (now - timedelta(hours=1)).isoformat()
newer = (now + timedelta(hours=1)).isoformat()
our_page = {
'layout': {
'elements': [
{
'uuid': 'elem-1',
'text_content': 'Older version',
'last_modified': older
}
]
}
}
our_page = {"layout": {"elements": [{"uuid": "elem-1", "text_content": "Older version", "last_modified": older}]}}
our_elem = our_page['layout']['elements'][0]
their_elem = {
'uuid': 'elem-1',
'text_content': 'Newer version',
'last_modified': newer
}
our_elem = our_page["layout"]["elements"][0]
their_elem = {"uuid": "elem-1", "text_content": "Newer version", "last_modified": newer}
# Test: their version is newer, should replace
merge_manager._merge_by_timestamp(our_page, 'elem-1', their_elem, our_elem)
merge_manager._merge_by_timestamp(our_page, "elem-1", their_elem, our_elem)
assert our_page['layout']['elements'][0]['text_content'] == 'Newer version'
assert our_page["layout"]["elements"][0]["text_content"] == "Newer version"
print(f" ✓ Correctly replaced with newer version")
# Test: our version is newer, should not replace
our_page['layout']['elements'][0] = {
'uuid': 'elem-2',
'text_content': 'Our newer version',
'last_modified': newer
}
their_elem_older = {
'uuid': 'elem-2',
'text_content': 'Their older version',
'last_modified': older
}
our_page["layout"]["elements"][0] = {"uuid": "elem-2", "text_content": "Our newer version", "last_modified": newer}
their_elem_older = {"uuid": "elem-2", "text_content": "Their older version", "last_modified": older}
merge_manager._merge_by_timestamp(
our_page, 'elem-2',
their_elem_older,
our_page['layout']['elements'][0]
)
merge_manager._merge_by_timestamp(our_page, "elem-2", their_elem_older, our_page["layout"]["elements"][0])
assert our_page['layout']['elements'][0]['text_content'] == 'Our newer version'
assert our_page["layout"]["elements"][0]["text_content"] == "Our newer version"
print(f" ✓ Correctly kept our newer version")
print(f"\n{'=' * 60}")
@ -430,73 +402,49 @@ def test_merge_helper_merge_element():
# Setup: page with one element
our_page = {
'uuid': 'page-1',
'layout': {
'elements': [
{
'uuid': 'elem-existing',
'text_content': 'Existing',
'last_modified': now
}
]
}
"uuid": "page-1",
"layout": {"elements": [{"uuid": "elem-existing", "text_content": "Existing", "last_modified": now}]},
}
our_elements = {
'elem-existing': our_page['layout']['elements'][0]
}
our_elements = {"elem-existing": our_page["layout"]["elements"][0]}
# Test 1: Adding new element
their_new_elem = {
'uuid': 'elem-new',
'text_content': 'New element',
'last_modified': now
}
their_new_elem = {"uuid": "elem-new", "text_content": "New element", "last_modified": now}
merge_manager._merge_element(
our_page=our_page,
page_uuid='page-1',
their_elem=their_new_elem,
our_elements=our_elements
our_page=our_page, page_uuid="page-1", their_elem=their_new_elem, our_elements=our_elements
)
assert len(our_page['layout']['elements']) == 2
assert our_page['layout']['elements'][1]['uuid'] == 'elem-new'
assert len(our_page["layout"]["elements"]) == 2
assert our_page["layout"]["elements"][1]["uuid"] == "elem-new"
print(f" ✓ Correctly added new element")
# Test 2: Element in conflict should be skipped
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
conflict_elem = {
'uuid': 'elem-conflict',
'text_content': 'Conflict element',
'last_modified': now
}
conflict_elem = {"uuid": "elem-conflict", "text_content": "Conflict element", "last_modified": now}
conflict = ConflictInfo(
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
page_uuid='page-1',
element_uuid='elem-conflict',
page_uuid="page-1",
element_uuid="elem-conflict",
our_version={},
their_version={},
description="Test"
description="Test",
)
merge_manager.conflicts.append(conflict)
our_elements['elem-conflict'] = {'uuid': 'elem-conflict', 'text_content': 'Ours'}
our_page['layout']['elements'].append(our_elements['elem-conflict'])
our_elements["elem-conflict"] = {"uuid": "elem-conflict", "text_content": "Ours"}
our_page["layout"]["elements"].append(our_elements["elem-conflict"])
initial_count = len(our_page['layout']['elements'])
initial_count = len(our_page["layout"]["elements"])
merge_manager._merge_element(
our_page=our_page,
page_uuid='page-1',
their_elem=conflict_elem,
our_elements=our_elements
our_page=our_page, page_uuid="page-1", their_elem=conflict_elem, our_elements=our_elements
)
# Should not change anything since it's in conflict
assert len(our_page['layout']['elements']) == initial_count
assert len(our_page["layout"]["elements"]) == initial_count
print(f" ✓ Correctly skipped conflicting element")
print(f"\n{'=' * 60}")

View File

@ -49,29 +49,16 @@ def create_v2_project_json():
"rotation": 0,
"z_index": 0,
"text_content": "Hello v2.0",
"font_settings": {
"family": "Arial",
"size": 12,
"color": [0, 0, 0]
},
"alignment": "left"
"font_settings": {"family": "Arial", "size": 12, "color": [0, 0, 0]},
"alignment": "left",
}
],
"snapping_system": {
"snap_threshold_mm": 5.0,
"grid_size_mm": 10.0
}
}
"snapping_system": {"snap_threshold_mm": 5.0, "grid_size_mm": 10.0},
},
}
],
"history": {
"undo_stack": [],
"redo_stack": [],
"max_history": 100
},
"asset_manager": {
"reference_counts": {}
}
"history": {"undo_stack": [], "redo_stack": [], "max_history": 100},
"asset_manager": {"reference_counts": {}},
}
@ -88,9 +75,9 @@ def test_migration():
print(f"\n1. Creating v2.0 project file: {v2_file}")
v2_data = create_v2_project_json()
with zipfile.ZipFile(v2_file, 'w', zipfile.ZIP_DEFLATED) as zipf:
with zipfile.ZipFile(v2_file, "w", zipfile.ZIP_DEFLATED) as zipf:
project_json = json.dumps(v2_data, indent=2)
zipf.writestr('project.json', project_json)
zipf.writestr("project.json", project_json)
print(f" ✓ Created v2.0 project with {len(v2_data['pages'])} page(s)")
print(f" ✓ Version: {v2_data['data_version']}")
@ -107,9 +94,9 @@ def test_migration():
print(f"\n3. Verifying migration to v3.0...")
# Check project-level fields
assert hasattr(project, 'project_id'), "Missing project_id"
assert hasattr(project, 'created'), "Missing created timestamp"
assert hasattr(project, 'last_modified'), "Missing last_modified timestamp"
assert hasattr(project, "project_id"), "Missing project_id"
assert hasattr(project, "created"), "Missing created timestamp"
assert hasattr(project, "last_modified"), "Missing last_modified timestamp"
print(f" ✓ Project has project_id: {project.project_id}")
print(f" ✓ Project has created: {project.created}")
print(f" ✓ Project has last_modified: {project.last_modified}")
@ -117,20 +104,20 @@ def test_migration():
# Check page-level fields
assert len(project.pages) > 0, "No pages in project"
page = project.pages[0]
assert hasattr(page, 'uuid'), "Page missing uuid"
assert hasattr(page, 'created'), "Page missing created"
assert hasattr(page, 'last_modified'), "Page missing last_modified"
assert hasattr(page, 'deleted'), "Page missing deleted flag"
assert hasattr(page, "uuid"), "Page missing uuid"
assert hasattr(page, "created"), "Page missing created"
assert hasattr(page, "last_modified"), "Page missing last_modified"
assert hasattr(page, "deleted"), "Page missing deleted flag"
print(f" ✓ Page 1 has uuid: {page.uuid}")
print(f" ✓ Page 1 has timestamps and deletion tracking")
# Check element-level fields
assert len(page.layout.elements) > 0, "No elements in page"
element = page.layout.elements[0]
assert hasattr(element, 'uuid'), "Element missing uuid"
assert hasattr(element, 'created'), "Element missing created"
assert hasattr(element, 'last_modified'), "Element missing last_modified"
assert hasattr(element, 'deleted'), "Element missing deleted flag"
assert hasattr(element, "uuid"), "Element missing uuid"
assert hasattr(element, "created"), "Element missing created"
assert hasattr(element, "last_modified"), "Element missing last_modified"
assert hasattr(element, "deleted"), "Element missing deleted flag"
print(f" ✓ Element has uuid: {element.uuid}")
print(f" ✓ Element has timestamps and deletion tracking")
@ -142,14 +129,14 @@ def test_migration():
print(f" ✓ Saved to: {v3_file}")
# Verify v3.0 file structure
with zipfile.ZipFile(v3_file, 'r') as zipf:
project_json = zipf.read('project.json').decode('utf-8')
with zipfile.ZipFile(v3_file, "r") as zipf:
project_json = zipf.read("project.json").decode("utf-8")
v3_data = json.loads(project_json)
assert v3_data.get('data_version') == "3.0", "Wrong version"
assert 'project_id' in v3_data, "Missing project_id in saved file"
assert 'created' in v3_data, "Missing created in saved file"
assert 'uuid' in v3_data['pages'][0], "Missing page uuid in saved file"
assert v3_data.get("data_version") == "3.0", "Wrong version"
assert "project_id" in v3_data, "Missing project_id in saved file"
assert "created" in v3_data, "Missing created in saved file"
assert "uuid" in v3_data["pages"][0], "Missing page uuid in saved file"
print(f" ✓ Saved file version: {v3_data.get('data_version')}")
print(f" ✓ All v3.0 fields present in saved file")
@ -164,6 +151,7 @@ def test_migration():
print(f"❌ Migration test FAILED: {e}")
print(f"{'=' * 60}\n")
import traceback
traceback.print_exc()
return False

View File

@ -30,15 +30,7 @@ class TestImageData:
def test_initialization_with_parameters(self, temp_image_file):
"""Test ImageData initialization with custom parameters"""
img = ImageData(
image_path=temp_image_file,
x=10.0,
y=20.0,
width=200.0,
height=150.0,
rotation=45.0,
z_index=5
)
img = ImageData(image_path=temp_image_file, x=10.0, y=20.0, width=200.0, height=150.0, rotation=45.0, z_index=5)
assert img.image_path == temp_image_file
assert img.position == (10.0, 20.0)
assert img.size == (200.0, 150.0)
@ -53,15 +45,7 @@ class TestImageData:
def test_serialization(self, temp_image_file):
"""Test ImageData serialization to dictionary"""
img = ImageData(
image_path=temp_image_file,
x=15.0,
y=25.0,
width=180.0,
height=120.0,
rotation=30.0,
z_index=3
)
img = ImageData(image_path=temp_image_file, x=15.0, y=25.0, width=180.0, height=120.0, rotation=30.0, z_index=3)
data = img.serialize()
assert data["type"] == "image"
@ -81,7 +65,7 @@ class TestImageData:
"rotation": 90.0,
"z_index": 7,
"image_path": "new_image.jpg",
"crop_info": (0.2, 0.3, 0.7, 0.8)
"crop_info": (0.2, 0.3, 0.7, 0.8),
}
img.deserialize(data)
@ -118,7 +102,7 @@ class TestImageData:
height=200.0,
rotation=0, # Visual rotation should be 0 for images
z_index=2,
crop_info=(0.1, 0.1, 0.9, 0.9)
crop_info=(0.1, 0.1, 0.9, 0.9),
)
original.pil_rotation_90 = 1 # Set PIL rotation to 90 degrees
@ -170,7 +154,7 @@ class TestPlaceholderData:
width=150.0,
height=100.0,
rotation=10.0,
z_index=4
z_index=4,
)
assert placeholder.placeholder_type == "text"
assert placeholder.default_content == "Sample"
@ -189,7 +173,7 @@ class TestPlaceholderData:
width=200.0,
height=150.0,
rotation=20.0,
z_index=2
z_index=2,
)
data = placeholder.serialize()
@ -210,7 +194,7 @@ class TestPlaceholderData:
"rotation": 45.0,
"z_index": 6,
"placeholder_type": "text",
"default_content": "Default Text"
"default_content": "Default Text",
}
placeholder.deserialize(data)
@ -243,7 +227,7 @@ class TestPlaceholderData:
width=300.0,
height=250.0,
rotation=60.0,
z_index=8
z_index=8,
)
data = original.serialize()
restored = PlaceholderData()
@ -283,7 +267,7 @@ class TestTextBoxData:
width=180.0,
height=60.0,
rotation=5.0,
z_index=3
z_index=3,
)
assert textbox.text_content == "Hello World"
assert textbox.font_settings == font_settings
@ -305,7 +289,7 @@ class TestTextBoxData:
width=220.0,
height=80.0,
rotation=15.0,
z_index=5
z_index=5,
)
data = textbox.serialize()
@ -329,7 +313,7 @@ class TestTextBoxData:
"z_index": 7,
"text_content": "Deserialized Text",
"font_settings": font_settings,
"alignment": "justify"
"alignment": "justify",
}
textbox.deserialize(data)
@ -366,7 +350,7 @@ class TestTextBoxData:
width=320.0,
height=120.0,
rotation=25.0,
z_index=9
z_index=9,
)
data = original.serialize()
restored = TextBoxData()

View File

@ -28,7 +28,7 @@ class TestMouseInteractionWidget(
ElementSelectionMixin,
ViewportMixin,
UndoableInteractionMixin,
QOpenGLWidget
QOpenGLWidget,
):
"""Test widget combining mouse interaction with other required mixins"""
@ -49,9 +49,9 @@ class TestMouseInteractionInitialization:
qtbot.addWidget(widget)
# Should have initialized state
assert hasattr(widget, 'drag_start_pos')
assert hasattr(widget, 'is_dragging')
assert hasattr(widget, 'is_panning')
assert hasattr(widget, "drag_start_pos")
assert hasattr(widget, "is_dragging")
assert hasattr(widget, "is_panning")
assert widget.drag_start_pos is None
assert widget.is_dragging is False
assert widget.is_panning is False
@ -123,8 +123,11 @@ class TestMousePressEvent:
# Create image element with crop info
element = ImageData(
image_path="/test.jpg",
x=50, y=50, width=100, height=100,
crop_info=(0.0, 0.0, 1.0, 1.0) # crop_info is a tuple (x, y, width, height)
x=50,
y=50,
width=100,
height=100,
crop_info=(0.0, 0.0, 1.0, 1.0), # crop_info is a tuple (x, y, width, height)
)
event = Mock()
@ -205,7 +208,7 @@ class TestMouseMoveEvent:
event.buttons = Mock(return_value=Qt.MouseButton.NoButton)
# Mock resize handle detection
widget._get_resize_handle_at = Mock(return_value='bottom-right')
widget._get_resize_handle_at = Mock(return_value="bottom-right")
widget._get_element_at = Mock(return_value=element)
widget.mouseMoveEvent(event)
@ -295,9 +298,11 @@ class TestMouseMoveEvent:
# Create image element with crop info
element = ImageData(
image_path="/test.jpg",
x=100, y=100,
width=100, height=100,
crop_info=(0.0, 0.0, 1.0, 1.0) # crop_info is a tuple (x, y, width, height)
x=100,
y=100,
width=100,
height=100,
crop_info=(0.0, 0.0, 1.0, 1.0), # crop_info is a tuple (x, y, width, height)
)
widget.selected_elements.add(element)
@ -377,9 +382,11 @@ class TestMouseDoubleClickEvent:
# Create text element with correct constructor
text_element = TextBoxData(
text_content="Test",
x=100, y=100,
width=100, height=50,
font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)}
x=100,
y=100,
width=100,
height=50,
font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)},
)
# Mock _edit_text_element method
@ -394,7 +401,7 @@ class TestMouseDoubleClickEvent:
QPointF(125, 125),
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier
Qt.KeyboardModifier.NoModifier,
)
widget.mouseDoubleClickEvent(event)
@ -419,7 +426,7 @@ class TestMouseDoubleClickEvent:
QPointF(125, 125),
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier
Qt.KeyboardModifier.NoModifier,
)
widget.mouseDoubleClickEvent(event)

View File

@ -16,6 +16,7 @@ from pyPhotoAlbum.page_layout import PageLayout
# Create a minimal test widget class that doesn't require full GLWidget initialization
class MultiSelectTestWidget(ElementSelectionMixin, RenderingMixin, QOpenGLWidget):
"""Widget combining necessary mixins for multiselect testing"""
def __init__(self):
super().__init__()
self._page_renderers = []
@ -69,7 +70,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 1: Single selection")
widget.selected_elements = {element1}
with patch.object(widget, '_draw_selection_handles') as mock_draw:
with patch.object(widget, "_draw_selection_handles") as mock_draw:
# Simulate paintGL call (only the relevant part)
for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem)
@ -82,7 +83,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 2: Multiple selection (2 elements)")
widget.selected_elements = {element1, element2}
with patch.object(widget, '_draw_selection_handles') as mock_draw:
with patch.object(widget, "_draw_selection_handles") as mock_draw:
for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem)
@ -95,7 +96,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 3: Multiple selection (3 elements)")
widget.selected_elements = {element1, element2, element3}
with patch.object(widget, '_draw_selection_handles') as mock_draw:
with patch.object(widget, "_draw_selection_handles") as mock_draw:
for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem)
@ -108,7 +109,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 4: No selection")
widget.selected_elements = set()
with patch.object(widget, '_draw_selection_handles') as mock_draw:
with patch.object(widget, "_draw_selection_handles") as mock_draw:
for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem)
@ -120,15 +121,17 @@ def test_multiselect_visual_feedback(qtbot):
widget.selected_elements = {element2}
# Mock OpenGL functions
with patch('pyPhotoAlbum.gl_widget.glColor3f'), \
patch('pyPhotoAlbum.gl_widget.glLineWidth'), \
patch('pyPhotoAlbum.gl_widget.glBegin'), \
patch('pyPhotoAlbum.gl_widget.glEnd'), \
patch('pyPhotoAlbum.gl_widget.glVertex2f'), \
patch('pyPhotoAlbum.gl_widget.glPushMatrix'), \
patch('pyPhotoAlbum.gl_widget.glPopMatrix'), \
patch('pyPhotoAlbum.gl_widget.glTranslatef'), \
patch('pyPhotoAlbum.gl_widget.glRotatef'):
with (
patch("pyPhotoAlbum.gl_widget.glColor3f"),
patch("pyPhotoAlbum.gl_widget.glLineWidth"),
patch("pyPhotoAlbum.gl_widget.glBegin"),
patch("pyPhotoAlbum.gl_widget.glEnd"),
patch("pyPhotoAlbum.gl_widget.glVertex2f"),
patch("pyPhotoAlbum.gl_widget.glPushMatrix"),
patch("pyPhotoAlbum.gl_widget.glPopMatrix"),
patch("pyPhotoAlbum.gl_widget.glTranslatef"),
patch("pyPhotoAlbum.gl_widget.glRotatef"),
):
# Call the actual method
widget._draw_selection_handles(element2)

View File

@ -174,7 +174,7 @@ class TestPageLayoutDeserialization:
"elements": [],
"grid_layout": None,
"snapping_system": {},
"show_snap_lines": False
"show_snap_lines": False,
}
layout.deserialize(data)
@ -198,7 +198,7 @@ class TestPageLayoutDeserialization:
"size": (100, 100),
"rotation": 0,
"z_index": 0,
"crop_info": (0, 0, 1, 1)
"crop_info": (0, 0, 1, 1),
},
{
"type": "placeholder",
@ -207,7 +207,7 @@ class TestPageLayoutDeserialization:
"position": (30, 40),
"size": (80, 80),
"rotation": 0,
"z_index": 1
"z_index": 1,
},
{
"type": "textbox",
@ -217,9 +217,9 @@ class TestPageLayoutDeserialization:
"position": (50, 60),
"size": (120, 40),
"rotation": 0,
"z_index": 2
}
]
"z_index": 2,
},
],
}
layout.deserialize(data)
@ -242,7 +242,7 @@ class TestPageLayoutDeserialization:
"size": (100, 100),
"rotation": 0,
"z_index": 5, # Higher z_index
"crop_info": (0, 0, 1, 1)
"crop_info": (0, 0, 1, 1),
},
{
"type": "placeholder",
@ -251,9 +251,9 @@ class TestPageLayoutDeserialization:
"position": (30, 40),
"size": (80, 80),
"rotation": 0,
"z_index": 1 # Lower z_index - should be first
}
]
"z_index": 1, # Lower z_index - should be first
},
],
}
layout.deserialize(data)
@ -269,12 +269,7 @@ class TestPageLayoutDeserialization:
data = {
"size": (210, 297),
"elements": [],
"grid_layout": {
"rows": 2,
"columns": 3,
"spacing": 12.5,
"merged_cells": [(0, 0), (1, 1)]
}
"grid_layout": {"rows": 2, "columns": 3, "spacing": 12.5, "merged_cells": [(0, 0), (1, 1)]},
}
layout.deserialize(data)
@ -291,10 +286,7 @@ class TestPageLayoutDeserialization:
data = {
"size": (210, 297),
"elements": [
{
"type": "unknown_type",
"position": (10, 20)
},
{"type": "unknown_type", "position": (10, 20)},
{
"type": "image",
"image_path": "test.jpg",
@ -302,9 +294,9 @@ class TestPageLayoutDeserialization:
"size": (100, 100),
"rotation": 0,
"z_index": 0,
"crop_info": (0, 0, 1, 1)
}
]
"crop_info": (0, 0, 1, 1),
},
],
}
layout.deserialize(data)
@ -430,12 +422,7 @@ class TestGridLayoutClass:
def test_grid_deserialization(self):
"""Test GridLayout deserialization"""
grid = GridLayout()
data = {
"rows": 4,
"columns": 5,
"spacing": 8.5,
"merged_cells": [(1, 2), (3, 3)]
}
data = {"rows": 4, "columns": 5, "spacing": 8.5, "merged_cells": [(1, 2), (3, 3)]}
grid.deserialize(data)

View File

@ -15,6 +15,7 @@ from pyPhotoAlbum.models import GhostPageData
# Create test widget combining necessary mixins
class TestPageNavWidget(PageNavigationMixin, ViewportMixin, QOpenGLWidget):
"""Test widget combining page navigation and viewport mixins"""
pass
@ -149,9 +150,7 @@ class TestGetPagePositions:
mock_window.project.pages = [page]
# Mock calculate_page_layout_with_ghosts
mock_window.project.calculate_page_layout_with_ghosts = Mock(return_value=[
('page', page, 0)
])
mock_window.project.calculate_page_layout_with_ghosts = Mock(return_value=[("page", page, 0)])
widget.window = Mock(return_value=mock_window)
@ -159,7 +158,7 @@ class TestGetPagePositions:
# Should have one page entry
assert len(result) >= 1
assert result[0][0] == 'page'
assert result[0][0] == "page"
assert result[0][1] is page
def test_get_page_positions_includes_ghosts(self, qtbot):
@ -177,10 +176,9 @@ class TestGetPagePositions:
mock_window.project.pages = [page]
# Mock with ghost page
mock_window.project.calculate_page_layout_with_ghosts = Mock(return_value=[
('page', page, 0),
('ghost', None, 1)
])
mock_window.project.calculate_page_layout_with_ghosts = Mock(
return_value=[("page", page, 0), ("ghost", None, 1)]
)
widget.window = Mock(return_value=mock_window)
@ -189,8 +187,8 @@ class TestGetPagePositions:
# Should have page + ghost
assert len(result) >= 2
page_types = [r[0] for r in result]
assert 'page' in page_types
assert 'ghost' in page_types
assert "page" in page_types
assert "ghost" in page_types
class TestCheckGhostPageClick:
@ -217,7 +215,7 @@ class TestCheckGhostPageClick:
result = widget._check_ghost_page_click(100, 100)
assert result is False
@patch('pyPhotoAlbum.page_renderer.PageRenderer')
@patch("pyPhotoAlbum.page_renderer.PageRenderer")
def test_check_ghost_page_click_on_ghost(self, mock_page_renderer_class, qtbot):
"""Test clicking on ghost page creates new page"""
widget = TestPageNavWidget()
@ -238,9 +236,7 @@ class TestCheckGhostPageClick:
# Mock _get_page_positions to return a ghost
ghost = GhostPageData(page_size=(210, 297))
widget._get_page_positions = Mock(return_value=[
('ghost', ghost, 100)
])
widget._get_page_positions = Mock(return_value=[("ghost", ghost, 100)])
# Mock PageRenderer to say click is in page
mock_renderer_instance = Mock()
@ -257,7 +253,7 @@ class TestCheckGhostPageClick:
assert len(mock_window.project.pages) == 1
assert widget.update.called
@patch('pyPhotoAlbum.page_renderer.PageRenderer')
@patch("pyPhotoAlbum.page_renderer.PageRenderer")
def test_check_ghost_page_click_outside_ghost(self, mock_page_renderer_class, qtbot):
"""Test clicking outside ghost page returns False"""
widget = TestPageNavWidget()
@ -273,9 +269,7 @@ class TestCheckGhostPageClick:
mock_window.project.pages = []
ghost = GhostPageData(page_size=(210, 297))
widget._get_page_positions = Mock(return_value=[
('ghost', ghost, 100)
])
widget._get_page_positions = Mock(return_value=[("ghost", ghost, 100)])
# Mock renderer to say click is NOT in page
mock_renderer_instance = Mock()

View File

@ -98,11 +98,7 @@ class TestGetMostVisiblePageIndex:
renderer3 = Mock()
renderer3.screen_y = 800
window.gl_widget._page_renderers = [
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
result = window._get_most_visible_page_index()
# Page 2 (index 1) should be closest to viewport center
@ -122,10 +118,7 @@ class TestGetMostVisiblePageIndex:
renderer_orphan = Mock()
renderer_orphan.screen_y = 50 # Closer to center
window.gl_widget._page_renderers = [
(renderer1, page1),
(renderer_orphan, orphan_page) # Not in project.pages
]
window.gl_widget._page_renderers = [(renderer1, page1), (renderer_orphan, orphan_page)] # Not in project.pages
window.gl_widget.current_page_index = 0
result = window._get_most_visible_page_index()
@ -222,11 +215,7 @@ class TestToggleDoubleSpread:
renderer3 = Mock()
renderer3.screen_y = 800
window.gl_widget._page_renderers = [
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
window.toggle_double_spread()
@ -340,11 +329,7 @@ class TestAddPage:
renderer3 = Mock()
renderer3.screen_y = 800
window.gl_widget._page_renderers = [
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
window.add_page()
@ -387,11 +372,7 @@ class TestAddPage:
renderer3 = Mock()
renderer3.screen_y = 1500
window.gl_widget._page_renderers = [
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
window.add_page()
@ -458,11 +439,7 @@ class TestRemovePage:
renderer3 = Mock()
renderer3.screen_y = -300 # Page 3 is most visible
window.gl_widget._page_renderers = [
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
window.remove_page()

View File

@ -12,12 +12,7 @@ class TestPageRendererCoordinates:
def test_page_to_screen_no_zoom_no_pan(self):
"""Test page_to_screen conversion with zoom=1.0 and no pan"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
# Element at page origin should map to screen_x, screen_y
@ -33,12 +28,7 @@ class TestPageRendererCoordinates:
def test_page_to_screen_with_zoom(self):
"""Test page_to_screen conversion with zoom applied"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=2.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=2.0
)
# With zoom=2.0, distances should be doubled
@ -49,12 +39,7 @@ class TestPageRendererCoordinates:
def test_page_to_screen_with_fractional_zoom(self):
"""Test page_to_screen conversion with fractional zoom"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=0.5
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=0.5
)
# With zoom=0.5, distances should be halved
@ -65,12 +50,7 @@ class TestPageRendererCoordinates:
def test_screen_to_page_no_zoom_no_pan(self):
"""Test screen_to_page conversion with zoom=1.0 and no pan"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
# Screen position at screen_x, screen_y should map to page origin
@ -86,12 +66,7 @@ class TestPageRendererCoordinates:
def test_screen_to_page_with_zoom(self):
"""Test screen_to_page conversion with zoom applied"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=2.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=2.0
)
# With zoom=2.0, screen distances should be divided by 2 to get page coords
@ -102,12 +77,7 @@ class TestPageRendererCoordinates:
def test_roundtrip_conversion_no_zoom(self):
"""Test that page->screen->page conversion is accurate with no zoom"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
# Start with page coordinates
@ -124,12 +94,7 @@ class TestPageRendererCoordinates:
def test_roundtrip_conversion_with_zoom(self):
"""Test that page->screen->page conversion is accurate with zoom"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.5
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.5
)
# Start with page coordinates
@ -147,12 +112,7 @@ class TestPageRendererCoordinates:
"""Test coordinate conversion with extreme zoom levels"""
for zoom in [0.1, 0.5, 1.0, 2.0, 5.0]:
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=50.0,
screen_y=100.0,
dpi=96,
zoom=zoom
page_width_mm=210.0, page_height_mm=297.0, screen_x=50.0, screen_y=100.0, dpi=96, zoom=zoom
)
orig_page_x, orig_page_y = 250.0, 400.0
@ -174,7 +134,7 @@ class TestPageRendererBounds:
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
zoom=1.0,
)
# Calculate page dimensions in pixels
@ -190,20 +150,12 @@ class TestPageRendererBounds:
assert renderer.is_point_in_page(100.0, 200.0)
# Point at bottom-right corner should be inside
assert renderer.is_point_in_page(
100.0 + page_width_px,
200.0 + page_height_px
)
assert renderer.is_point_in_page(100.0 + page_width_px, 200.0 + page_height_px)
def test_is_point_in_page_outside(self):
"""Test is_point_in_page for points outside the page"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
# Point before page start
@ -222,12 +174,7 @@ class TestPageRendererBounds:
"""Test is_point_in_page with different zoom levels"""
for zoom in [0.5, 1.0, 2.0]:
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=zoom
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=zoom
)
# Center of page should always be inside regardless of zoom
@ -240,12 +187,7 @@ class TestPageRendererBounds:
def test_get_page_bounds_screen(self):
"""Test get_page_bounds_screen returns correct screen coordinates"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.5
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.5
)
x, y, w, h = renderer.get_page_bounds_screen()
@ -262,12 +204,7 @@ class TestPageRendererBounds:
def test_get_page_bounds_page(self):
"""Test get_page_bounds_page returns correct page-local coordinates"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.5
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.5
)
x, y, w, h = renderer.get_page_bounds_page()
@ -289,12 +226,7 @@ class TestPageRendererSubPages:
def test_get_sub_page_at_single_page(self):
"""Test that get_sub_page_at returns None for single pages"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
# For non-facing pages, should return None
@ -309,7 +241,7 @@ class TestPageRendererSubPages:
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
zoom=1.0,
)
# Calculate center line
@ -318,7 +250,7 @@ class TestPageRendererSubPages:
# Point before center should be 'left'
result = renderer.get_sub_page_at(center_x - 10, is_facing_page=True)
assert result == 'left'
assert result == "left"
def test_get_sub_page_at_facing_page_right(self):
"""Test get_sub_page_at for right side of facing page"""
@ -328,7 +260,7 @@ class TestPageRendererSubPages:
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
zoom=1.0,
)
# Calculate center line
@ -337,7 +269,7 @@ class TestPageRendererSubPages:
# Point after center should be 'right'
result = renderer.get_sub_page_at(center_x + 10, is_facing_page=True)
assert result == 'right'
assert result == "right"
class TestPageRendererDimensions:
@ -351,7 +283,7 @@ class TestPageRendererDimensions:
screen_x=0.0,
screen_y=0.0,
dpi=96,
zoom=1.0
zoom=1.0,
)
# A4 at 96 DPI
@ -363,14 +295,7 @@ class TestPageRendererDimensions:
def test_screen_dimensions_with_zoom(self):
"""Test that screen dimensions account for zoom"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=0.0,
screen_y=0.0,
dpi=96,
zoom=2.0
)
renderer = PageRenderer(page_width_mm=210.0, page_height_mm=297.0, screen_x=0.0, screen_y=0.0, dpi=96, zoom=2.0)
# Screen dimensions should be doubled due to zoom
expected_width = (210.0 * 96 / 25.4) * 2.0
@ -385,12 +310,7 @@ class TestPageRendererDimensions:
for dpi in dpi_values:
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=0.0,
screen_y=0.0,
dpi=dpi,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=0.0, screen_y=0.0, dpi=dpi, zoom=1.0
)
expected_width = 210.0 * dpi / 25.4
@ -406,12 +326,7 @@ class TestPageRendererEdgeCases:
def test_zero_coordinates(self):
"""Test handling of zero coordinates"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
screen_x, screen_y = renderer.page_to_screen(0, 0)
@ -425,12 +340,7 @@ class TestPageRendererEdgeCases:
def test_negative_page_coordinates(self):
"""Test handling of negative page coordinates"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
# Negative page coordinates should still convert correctly
@ -446,12 +356,7 @@ class TestPageRendererEdgeCases:
def test_very_large_coordinates(self):
"""Test handling of very large coordinates"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
large_x, large_y = 10000.0, 20000.0

View File

@ -190,16 +190,16 @@ class TestPageSetupDialog:
values = dialog.get_values()
# Check all values returned
assert values['selected_index'] == 0
assert values['selected_page'] == page
assert values['is_cover'] is True
assert values['paper_thickness_mm'] == 0.15
assert values['cover_bleed_mm'] == 5.0
assert values['width_mm'] == 200
assert values['height_mm'] == 280
assert values['working_dpi'] == 150
assert values['export_dpi'] == 600
assert values['set_as_default'] is True
assert values["selected_index"] == 0
assert values["selected_page"] == page
assert values["is_cover"] is True
assert values["paper_thickness_mm"] == 0.15
assert values["cover_bleed_mm"] == 5.0
assert values["width_mm"] == 200
assert values["height_mm"] == 280
assert values["working_dpi"] == 150
assert values["export_dpi"] == 600
assert values["set_as_default"] is True
def test_dialog_page_change_updates_values(self, qtbot):
"""Test changing selected page updates displayed values"""
@ -240,14 +240,14 @@ class TestDialogMixin:
# Create mock dialog with get_values as a proper method
mock_dialog = MagicMock(spec=QDialog)
mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted)
mock_dialog.get_values = Mock(return_value={'test': 'value'})
mock_dialog.get_values = Mock(return_value={"test": "value"})
# Mock dialog class
mock_dialog_class = Mock(return_value=mock_dialog)
result = window.create_dialog(mock_dialog_class)
assert result == {'test': 'value'}
assert result == {"test": "value"}
mock_dialog.exec.assert_called_once()
def test_dialog_mixin_create_dialog_rejected(self, qtbot):
@ -283,7 +283,7 @@ class TestDialogMixin:
# Create mock dialog with get_values as a proper method
mock_dialog = MagicMock(spec=QDialog)
mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted)
mock_dialog.get_values = Mock(return_value={'test': 'value'})
mock_dialog.get_values = Mock(return_value={"test": "value"})
# Mock dialog class
mock_dialog_class = Mock(return_value=mock_dialog)
@ -294,7 +294,7 @@ class TestDialogMixin:
result = window.show_dialog(mock_dialog_class, on_accept=callback)
assert result is True
callback.assert_called_once_with({'test': 'value'})
callback.assert_called_once_with({"test": "value"})
class TestDialogActionDecorator:
@ -362,7 +362,7 @@ class TestDialogMixinEdgeCases:
mock_dialog = MagicMock(spec=QDialog)
mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted)
mock_dialog.get_values = Mock(return_value={'data': 'test'})
mock_dialog.get_values = Mock(return_value={"data": "test"})
mock_dialog_class = Mock(return_value=mock_dialog)
@ -370,7 +370,7 @@ class TestDialogMixinEdgeCases:
# Verify setWindowTitle was called
mock_dialog.setWindowTitle.assert_called_once_with("Custom Title")
assert result == {'data': 'test'}
assert result == {"data": "test"}
def test_show_dialog_rejected(self, qtbot):
"""Test show_dialog when user rejects dialog"""
@ -543,16 +543,16 @@ class TestPageSetupIntegration:
# Create mock values that would come from dialog
values = {
'selected_index': 0,
'selected_page': window.project.pages[0],
'is_cover': False,
'paper_thickness_mm': 0.15,
'cover_bleed_mm': 5.0,
'width_mm': 200,
'height_mm': 280,
'working_dpi': 150,
'export_dpi': 600,
'set_as_default': True
"selected_index": 0,
"selected_page": window.project.pages[0],
"is_cover": False,
"paper_thickness_mm": 0.15,
"cover_bleed_mm": 5.0,
"width_mm": 200,
"height_mm": 280,
"working_dpi": 150,
"export_dpi": 600,
"set_as_default": True,
}
# Access the unwrapped function to test business logic directly
@ -564,7 +564,7 @@ class TestPageSetupIntegration:
original_func = window.page_setup
# Decorators return wrappers, but we can call them with values directly
# by accessing the innermost wrapped function
while hasattr(original_func, '__wrapped__'):
while hasattr(original_func, "__wrapped__"):
original_func = original_func.__wrapped__
# If no __wrapped__, the decorator system is different
@ -575,7 +575,7 @@ class TestPageSetupIntegration:
# Get the undecorated method from the class
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
# Find the innermost function
while hasattr(undecorated_page_setup, '__wrapped__'):
while hasattr(undecorated_page_setup, "__wrapped__"):
undecorated_page_setup = undecorated_page_setup.__wrapped__
# Call the business logic directly
@ -635,22 +635,23 @@ class TestPageSetupIntegration:
# Test designating first page as cover
values = {
'selected_index': 0,
'selected_page': window.project.pages[0],
'is_cover': True, # Designate as cover
'paper_thickness_mm': 0.1,
'cover_bleed_mm': 3.0,
'width_mm': 210,
'height_mm': 297,
'working_dpi': 96,
'export_dpi': 300,
'set_as_default': False
"selected_index": 0,
"selected_page": window.project.pages[0],
"is_cover": True, # Designate as cover
"paper_thickness_mm": 0.1,
"cover_bleed_mm": 3.0,
"width_mm": 210,
"height_mm": 297,
"working_dpi": 96,
"export_dpi": 300,
"set_as_default": False,
}
# Get the undecorated method
from pyPhotoAlbum.mixins.operations import page_ops
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
while hasattr(undecorated_page_setup, '__wrapped__'):
while hasattr(undecorated_page_setup, "__wrapped__"):
undecorated_page_setup = undecorated_page_setup.__wrapped__
# Mock update_cover_dimensions
@ -706,21 +707,22 @@ class TestPageSetupIntegration:
# Test changing double spread page size
values = {
'selected_index': 0,
'selected_page': window.project.pages[0],
'is_cover': False,
'paper_thickness_mm': 0.1,
'cover_bleed_mm': 3.0,
'width_mm': 200, # New base width
'height_mm': 280, # New height
'working_dpi': 96,
'export_dpi': 300,
'set_as_default': False
"selected_index": 0,
"selected_page": window.project.pages[0],
"is_cover": False,
"paper_thickness_mm": 0.1,
"cover_bleed_mm": 3.0,
"width_mm": 200, # New base width
"height_mm": 280, # New height
"working_dpi": 96,
"export_dpi": 300,
"set_as_default": False,
}
from pyPhotoAlbum.mixins.operations import page_ops
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
while hasattr(undecorated_page_setup, '__wrapped__'):
while hasattr(undecorated_page_setup, "__wrapped__"):
undecorated_page_setup = undecorated_page_setup.__wrapped__
undecorated_page_setup(window, values)

View File

@ -26,7 +26,7 @@ class TestPageSetupDialogWithMocks:
# We can verify the class signature and that it would accept these params
# This is a structural test rather than a full initialization test
assert hasattr(PageSetupDialog, '__init__')
assert hasattr(PageSetupDialog, "__init__")
# The actual widget creation tests are in test_page_setup_dialog.py
# using qtbot which handles Qt properly
@ -44,7 +44,7 @@ class TestPageSetupDialogWithMocks:
project.pages = [page1, page2]
# Mock the dialog instance
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None):
with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0)
# Manually set required attributes
@ -88,7 +88,7 @@ class TestPageSetupDialogWithMocks:
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
project.pages = [page]
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None):
with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0)
dialog.project = project
dialog._cover_group = Mock()
@ -112,11 +112,11 @@ class TestPageSetupDialogWithMocks:
# Create 3 content pages (not covers)
for i in range(3):
page = Page(layout=PageLayout(width=210, height=297), page_number=i+1)
page = Page(layout=PageLayout(width=210, height=297), page_number=i + 1)
page.is_cover = False
project.pages.append(page)
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None):
with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0)
dialog.project = project
dialog.cover_checkbox = Mock()
@ -158,7 +158,7 @@ class TestPageSetupDialogWithMocks:
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
project.pages = [page]
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None):
with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0)
dialog.project = project
@ -194,16 +194,16 @@ class TestPageSetupDialogWithMocks:
values = dialog.get_values()
# Verify all values were extracted
assert values['selected_index'] == 0
assert values['selected_page'] == page
assert values['is_cover'] is True
assert values['paper_thickness_mm'] == 0.15
assert values['cover_bleed_mm'] == 5.0
assert values['width_mm'] == 200.0
assert values['height_mm'] == 280.0
assert values['working_dpi'] == 150
assert values['export_dpi'] == 600
assert values['set_as_default'] is True
assert values["selected_index"] == 0
assert values["selected_page"] == page
assert values["is_cover"] is True
assert values["paper_thickness_mm"] == 0.15
assert values["cover_bleed_mm"] == 5.0
assert values["width_mm"] == 200.0
assert values["height_mm"] == 280.0
assert values["working_dpi"] == 150
assert values["export_dpi"] == 600
assert values["set_as_default"] is True
def test_cover_page_width_display(self):
"""Test cover page shows full width, not base width"""
@ -217,7 +217,7 @@ class TestPageSetupDialogWithMocks:
page.is_cover = True
project.pages = [page]
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None):
with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0)
dialog.project = project
dialog._cover_group = Mock()
@ -259,7 +259,7 @@ class TestDialogMixinMocked:
# Mock dialog class
mock_dialog_instance = Mock()
mock_dialog_instance.exec.return_value = 1 # Accepted
mock_dialog_instance.get_values.return_value = {'key': 'value'}
mock_dialog_instance.get_values.return_value = {"key": "value"}
mock_dialog_class = Mock(return_value=mock_dialog_instance)
@ -279,7 +279,7 @@ class TestDialogMixinMocked:
mock_dialog_instance.get_values.assert_called_once()
# Verify result
assert result == {'key': 'value'}
assert result == {"key": "value"}
def test_show_dialog_with_callback_flow(self):
"""Test show_dialog method with callback"""
@ -293,7 +293,7 @@ class TestDialogMixinMocked:
# Mock dialog
mock_dialog_instance = Mock()
mock_dialog_instance.exec.return_value = 1 # Accepted
mock_dialog_instance.get_values.return_value = {'data': 'test'}
mock_dialog_instance.get_values.return_value = {"data": "test"}
mock_dialog_class = Mock(return_value=mock_dialog_instance)
@ -304,7 +304,7 @@ class TestDialogMixinMocked:
result = window.show_dialog(mock_dialog_class, on_accept=callback, param="value")
# Verify callback was called with dialog values
callback.assert_called_once_with({'data': 'test'})
callback.assert_called_once_with({"data": "test"})
# Verify result
assert result is True
@ -346,7 +346,7 @@ class TestDialogActionDecoratorMocked:
# Mock dialog instance
mock_dialog = Mock()
mock_dialog.exec.return_value = QDialog.DialogCode.Accepted # Accepted
mock_dialog.get_values.return_value = {'test': 'data'}
mock_dialog.get_values.return_value = {"test": "data"}
# Mock dialog class
mock_dialog_cls = Mock(return_value=mock_dialog)
@ -354,7 +354,7 @@ class TestDialogActionDecoratorMocked:
# Create decorated function
@dialog_action(dialog_class=mock_dialog_cls, requires_pages=True)
def test_function(self, values):
return values['test']
return values["test"]
# Mock instance with required attributes
instance = Mock()
@ -375,7 +375,7 @@ class TestDialogActionDecoratorMocked:
mock_dialog.get_values.assert_called_once()
# Verify original function received values
assert result == 'data'
assert result == "data"
def test_decorator_returns_early_when_no_pages(self):
"""Test decorator returns early when pages required but not present"""
@ -407,7 +407,7 @@ class TestDialogActionDecoratorMocked:
mock_dialog = Mock()
mock_dialog.exec.return_value = 1
mock_dialog.get_values.return_value = {'key': 'val'}
mock_dialog.get_values.return_value = {"key": "val"}
mock_dialog_cls = Mock(return_value=mock_dialog)
@ -427,8 +427,8 @@ class TestDialogActionDecoratorMocked:
mock_dialog_cls.assert_called_once()
# Verify result
assert result == {'key': 'val'}
assert result == {"key": "val"}
if __name__ == '__main__':
pytest.main([__file__, '-v'])
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@ -21,7 +21,7 @@ def test_pdf_exporter_basic():
project.add_page(page)
# Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name
try:
@ -51,7 +51,7 @@ def test_pdf_exporter_double_spread():
project.add_page(spread_page)
# Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name
try:
@ -83,14 +83,17 @@ def test_pdf_exporter_with_text():
text_content="Hello, World!",
font_settings={"family": "Helvetica", "size": 24, "color": (0, 0, 0)},
alignment="center",
x=50, y=50, width=100, height=30
x=50,
y=50,
width=100,
height=30,
)
page.layout.add_element(text_box)
project.add_page(page)
# Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name
try:
@ -143,7 +146,7 @@ def test_pdf_text_position_and_size():
x=text_box_x_px,
y=text_box_y_px,
width=text_box_width_px,
height=text_box_height_px
height=text_box_height_px,
)
page.layout.add_element(text_box)
project.add_page(page)
@ -168,7 +171,7 @@ def test_pdf_text_position_and_size():
expected_font_size_pt = font_size_px * 25.4 / dpi * MM_TO_POINTS
# Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name
try:
@ -186,16 +189,20 @@ def test_pdf_text_position_and_size():
# Get the first character's position and font size
first_char = chars[0]
text_x = first_char['x0']
text_y_baseline = first_char['y0'] # This is the baseline y position
actual_font_size = first_char['size']
text_x = first_char["x0"]
text_y_baseline = first_char["y0"] # This is the baseline y position
actual_font_size = first_char["size"]
print(f"\nText Position Analysis:")
print(f" Text box (in pixels at {dpi} DPI): x={text_box_x_px}, y={text_box_y_px}, "
f"w={text_box_width_px}, h={text_box_height_px}")
print(f" Text box (in PDF points): x={text_box_x_pt:.1f}, "
f"y_bottom={text_box_y_pt_bottom:.1f}, y_top={text_box_y_pt_top:.1f}, "
f"height={text_box_height_pt:.1f}")
print(
f" Text box (in pixels at {dpi} DPI): x={text_box_x_px}, y={text_box_y_px}, "
f"w={text_box_width_px}, h={text_box_height_px}"
)
print(
f" Text box (in PDF points): x={text_box_x_pt:.1f}, "
f"y_bottom={text_box_y_pt_bottom:.1f}, y_top={text_box_y_pt_top:.1f}, "
f"height={text_box_height_pt:.1f}"
)
print(f" Font size (pixels): {font_size_px}")
print(f" Expected font size (points): {expected_font_size_pt:.1f}")
print(f" Actual font size (points): {actual_font_size:.1f}")
@ -213,8 +220,7 @@ def test_pdf_text_position_and_size():
# Verify text X position is near the left edge of the text box
x_diff = abs(text_x - text_box_x_pt)
assert x_diff < 5.0, (
f"Text X position mismatch: expected ~{text_box_x_pt:.1f}, "
f"got {text_x:.1f} (diff: {x_diff:.1f}pt)"
f"Text X position mismatch: expected ~{text_box_x_pt:.1f}, " f"got {text_x:.1f} (diff: {x_diff:.1f}pt)"
)
# Verify text Y baseline is INSIDE the text box (not above it)
@ -280,7 +286,7 @@ def test_pdf_text_wrapping():
x=text_box_x_px,
y=text_box_y_px,
width=text_box_width_px,
height=text_box_height_px
height=text_box_height_px,
)
page.layout.add_element(text_box)
project.add_page(page)
@ -296,7 +302,7 @@ def test_pdf_text_wrapping():
text_box_right_pt = text_box_x_pt + text_box_width_pt
# Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name
try:
@ -313,7 +319,7 @@ def test_pdf_text_wrapping():
assert len(chars) > 0, "No text found in PDF"
# Get all unique Y positions (lines)
y_positions = sorted(set(round(c['top'], 1) for c in chars))
y_positions = sorted(set(round(c["top"], 1) for c in chars))
print(f"\nText Wrapping Analysis:")
print(f" Text box width: {text_box_width_pt:.1f}pt")
@ -322,21 +328,19 @@ def test_pdf_text_wrapping():
print(f" Line Y positions: {y_positions[:5]}...") # Show first 5
# Verify text wrapped to multiple lines
assert len(y_positions) > 1, (
f"Text should wrap to multiple lines but only found {len(y_positions)} line(s)"
)
assert len(y_positions) > 1, f"Text should wrap to multiple lines but only found {len(y_positions)} line(s)"
# Verify all characters are within box width (with small tolerance)
tolerance = 5.0 # Small tolerance for rounding
for char in chars:
char_x = char['x0']
char_right = char['x1']
assert char_x >= text_box_x_pt - tolerance, (
f"Character '{char['text']}' at x={char_x:.1f} is left of box start {text_box_x_pt:.1f}"
)
assert char_right <= text_box_right_pt + tolerance, (
f"Character '{char['text']}' ends at x={char_right:.1f} which exceeds box right {text_box_right_pt:.1f}"
)
char_x = char["x0"]
char_right = char["x1"]
assert (
char_x >= text_box_x_pt - tolerance
), f"Character '{char['text']}' at x={char_x:.1f} is left of box start {text_box_x_pt:.1f}"
assert (
char_right <= text_box_right_pt + tolerance
), f"Character '{char['text']}' ends at x={char_right:.1f} which exceeds box right {text_box_right_pt:.1f}"
print(f" All characters within box width: ✓")
print(f"\n✓ Text wrapping test passed!")
@ -361,7 +365,7 @@ def test_pdf_exporter_facing_pages_alignment():
project.add_page(spread)
# Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name
try:
@ -390,16 +394,13 @@ def test_pdf_exporter_missing_image():
page = Page(page_number=1, is_double_spread=False)
# Add image with non-existent path
image = ImageData(
image_path="/nonexistent/path/to/image.jpg",
x=50, y=50, width=100, height=100
)
image = ImageData(image_path="/nonexistent/path/to/image.jpg", x=50, y=50, width=100, height=100)
page.layout.add_element(image)
project.add_page(page)
# Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name
try:
@ -428,10 +429,10 @@ def test_pdf_exporter_spanning_image():
project.working_dpi = 96 # Standard DPI
# Create a test image (solid color for easy verification)
test_img = PILImage.new('RGB', (400, 200), color='red')
test_img = PILImage.new("RGB", (400, 200), color="red")
# Save test image to temporary file
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name
test_img.save(img_path)
@ -449,19 +450,13 @@ def test_pdf_exporter_spanning_image():
image_width_px = 400
image_x_px = center_px - 200 # Start 200px before center, end 200px after
spanning_image = ImageData(
image_path=img_path,
x=image_x_px,
y=100,
width=image_width_px,
height=200
)
spanning_image = ImageData(image_path=img_path, x=image_x_px, y=100, width=image_width_px, height=200)
spread_page.layout.add_element(spanning_image)
project.add_page(spread_page)
# Export to temporary PDF
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name
try:
@ -496,14 +491,14 @@ def test_pdf_exporter_multiple_spanning_elements():
project.working_dpi = 96
# Create test images
test_img1 = PILImage.new('RGB', (300, 150), color='blue')
test_img2 = PILImage.new('RGB', (250, 200), color='green')
test_img1 = PILImage.new("RGB", (300, 150), color="blue")
test_img2 = PILImage.new("RGB", (250, 200), color="green")
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp1:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp1:
img_path1 = img_tmp1.name
test_img1.save(img_path1)
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp2:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp2:
img_path2 = img_tmp2.name
test_img2.save(img_path2)
@ -516,28 +511,18 @@ def test_pdf_exporter_multiple_spanning_elements():
# First spanning image
image1 = ImageData(
image_path=img_path1,
x=center_px - 150, # Centered on split line
y=50,
width=300,
height=150
image_path=img_path1, x=center_px - 150, y=50, width=300, height=150 # Centered on split line
)
# Second spanning image (different position)
image2 = ImageData(
image_path=img_path2,
x=center_px - 100,
y=250,
width=250,
height=200
)
image2 = ImageData(image_path=img_path2, x=center_px - 100, y=250, width=250, height=200)
spread_page.layout.add_element(image1)
spread_page.layout.add_element(image2)
project.add_page(spread_page)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name
try:
@ -571,9 +556,9 @@ def test_pdf_exporter_edge_case_barely_spanning():
project.page_size_mm = (210, 297)
project.working_dpi = 96
test_img = PILImage.new('RGB', (100, 100), color='yellow')
test_img = PILImage.new("RGB", (100, 100), color="yellow")
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name
test_img.save(img_path)
@ -584,18 +569,12 @@ def test_pdf_exporter_edge_case_barely_spanning():
center_px = spread_width_px / 2
# Image that just barely crosses the center line
image = ImageData(
image_path=img_path,
x=center_px - 5, # Just 5px overlap
y=100,
width=100,
height=100
)
image = ImageData(image_path=img_path, x=center_px - 5, y=100, width=100, height=100) # Just 5px overlap
spread_page.layout.add_element(image)
project.add_page(spread_page)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name
try:
@ -636,13 +615,13 @@ def test_pdf_exporter_text_spanning():
x=center_px - 100,
y=100,
width=200,
height=50
height=50,
)
spread_page.layout.add_element(text_box)
project.add_page(spread_page)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name
try:
@ -671,7 +650,7 @@ def test_pdf_exporter_spanning_image_aspect_ratio():
# Create a distinctive test image: red left half, blue right half, with a vertical line in center
test_width, test_height = 800, 400
test_img = PILImage.new('RGB', (test_width, test_height))
test_img = PILImage.new("RGB", (test_width, test_height))
draw = ImageDraw.Draw(test_img)
# Fill left half red
@ -688,7 +667,7 @@ def test_pdf_exporter_spanning_image_aspect_ratio():
draw.line([0, y, test_width, y], fill=(255, 255, 255), width=2)
# Save test image to temporary file
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name
test_img.save(img_path)
@ -707,18 +686,14 @@ def test_pdf_exporter_spanning_image_aspect_ratio():
image_x_px = center_px - 300 # Centered on the split line
spanning_image = ImageData(
image_path=img_path,
x=image_x_px,
y=100,
width=image_width_px,
height=image_height_px
image_path=img_path, x=image_x_px, y=100, width=image_width_px, height=image_height_px
)
spread_page.layout.add_element(spanning_image)
project.add_page(spread_page)
# Export to temporary PDF
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name
try:
@ -765,10 +740,10 @@ def test_pdf_exporter_varying_aspect_ratios():
# Test different aspect ratios
test_configs = [
("Square", 400, 400), # 1:1
("Landscape", 800, 400), # 2:1
("Portrait", 400, 800), # 1:2
("Wide", 1200, 400), # 3:1
("Square", 400, 400), # 1:1
("Landscape", 800, 400), # 2:1
("Portrait", 400, 800), # 1:2
("Wide", 1200, 400), # 3:1
]
spread_width_px = 420 * 96 / 25.4
@ -776,15 +751,17 @@ def test_pdf_exporter_varying_aspect_ratios():
for idx, (name, img_w, img_h) in enumerate(test_configs):
# Create test image
test_img = PILImage.new('RGB', (img_w, img_h))
test_img = PILImage.new("RGB", (img_w, img_h))
draw = ImageDraw.Draw(test_img)
# Different colors for each test
colors = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0)]
draw.rectangle([0, 0, img_w // 2, img_h], fill=colors[idx])
draw.rectangle([img_w // 2, 0, img_w, img_h], fill=(255-colors[idx][0], 255-colors[idx][1], 255-colors[idx][2]))
draw.rectangle(
[img_w // 2, 0, img_w, img_h], fill=(255 - colors[idx][0], 255 - colors[idx][1], 255 - colors[idx][2])
)
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name
test_img.save(img_path)
@ -800,7 +777,7 @@ def test_pdf_exporter_varying_aspect_ratios():
x=center_px - 250,
y=100 + idx * 200,
width=element_width_px,
height=element_height_px
height=element_height_px,
)
spread_page.layout.add_element(spanning_image)
@ -811,7 +788,7 @@ def test_pdf_exporter_varying_aspect_ratios():
os.remove(img_path)
# Export all pages
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name
try:
@ -842,7 +819,7 @@ def test_pdf_exporter_rotated_image():
# Create a distinctive test image that shows rotation clearly
# Make it wider than tall (400x200) so we can verify rotation
test_img = PILImage.new('RGB', (400, 200), color='white')
test_img = PILImage.new("RGB", (400, 200), color="white")
draw = ImageDraw.Draw(test_img)
# Draw a pattern that shows orientation
@ -855,7 +832,7 @@ def test_pdf_exporter_rotated_image():
# Yellow vertical stripe on right
draw.rectangle([350, 0, 400, 200], fill=(255, 255, 0))
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name
test_img.save(img_path)
@ -865,11 +842,7 @@ def test_pdf_exporter_rotated_image():
# Add image with 90-degree PIL rotation
image = ImageData(
image_path=img_path,
x=50,
y=50,
width=200, # These dimensions are for the rotated version
height=400
image_path=img_path, x=50, y=50, width=200, height=400 # These dimensions are for the rotated version
)
image.pil_rotation_90 = 1 # 90 degree rotation
image.image_dimensions = (400, 200) # Original dimensions before rotation
@ -878,7 +851,7 @@ def test_pdf_exporter_rotated_image():
project.add_page(page)
# Export to PDF
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name
try:
@ -914,15 +887,16 @@ def test_pdf_exporter_image_downsampling():
project.working_dpi = 96
# Create a large test image (4000x3000 - typical high-res camera)
large_img = PILImage.new('RGB', (4000, 3000))
large_img = PILImage.new("RGB", (4000, 3000))
# Add some pattern so it doesn't compress too much
import random
pixels = large_img.load()
for i in range(0, 4000, 10):
for j in range(0, 3000, 10):
pixels[i, j] = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name
large_img.save(img_path)
@ -936,17 +910,17 @@ def test_pdf_exporter_image_downsampling():
x=50,
y=50,
width=int(100 * 96 / 25.4), # ~378 px
height=int(75 * 96 / 25.4) # ~283 px
height=int(75 * 96 / 25.4), # ~283 px
)
page.layout.add_element(image)
project.add_page(page)
# Export with high DPI (300 - print quality)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp1:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp1:
pdf_path_300dpi = pdf_tmp1.name
# Export with low DPI (150 - screen quality)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp2:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp2:
pdf_path_150dpi = pdf_tmp2.name
try:
@ -972,13 +946,13 @@ def test_pdf_exporter_image_downsampling():
print(f" Size reduction: {(1 - size_150dpi/size_300dpi)*100:.1f}%")
# 150 DPI should be smaller than 300 DPI
assert size_150dpi < size_300dpi, \
f"150 DPI file ({size_150dpi}) should be smaller than 300 DPI file ({size_300dpi})"
assert (
size_150dpi < size_300dpi
), f"150 DPI file ({size_150dpi}) should be smaller than 300 DPI file ({size_300dpi})"
# 150 DPI should be significantly smaller (at least 50% reduction)
reduction_ratio = size_150dpi / size_300dpi
assert reduction_ratio < 0.7, \
f"150 DPI should be at least 30% smaller, got {(1-reduction_ratio)*100:.1f}%"
assert reduction_ratio < 0.7, f"150 DPI should be at least 30% smaller, got {(1-reduction_ratio)*100:.1f}%"
finally:
if os.path.exists(pdf_path_300dpi):

View File

@ -133,14 +133,14 @@ class TestProject:
"""Test that project has an asset manager"""
project = Project()
assert hasattr(project, 'asset_manager')
assert hasattr(project, "asset_manager")
assert project.asset_manager is not None
def test_history_exists(self):
"""Test that project has a history manager"""
project = Project()
assert hasattr(project, 'history')
assert hasattr(project, "history")
assert project.history is not None
def test_pages_list_is_mutable(self):
@ -171,15 +171,9 @@ class TestProjectWithPages:
# Create pages with elements
for i in range(3):
layout = PageLayout()
img = ImageData(
image_path=f"image_{i}.jpg",
x=10 + i*10,
y=20 + i*10,
width=100,
height=100
)
img = ImageData(image_path=f"image_{i}.jpg", x=10 + i * 10, y=20 + i * 10, width=100, height=100)
layout.add_element(img)
page = Page(layout=layout, page_number=i+1)
page = Page(layout=layout, page_number=i + 1)
project.add_page(page)
assert len(project.pages) == 3
@ -213,7 +207,7 @@ class TestProjectWithPages:
project = Project()
for i in range(5):
page = Page(layout=PageLayout(), page_number=i+1)
page = Page(layout=PageLayout(), page_number=i + 1)
project.add_page(page)
# Clear all pages

View File

@ -41,7 +41,7 @@ def sample_image(temp_dir):
from PIL import Image
# Create a simple test image
img = Image.new('RGB', (100, 100), color='red')
img = Image.new("RGB", (100, 100), color="red")
image_path = os.path.join(temp_dir, "test_image.jpg")
img.save(image_path)
return image_path
@ -59,7 +59,7 @@ class TestBasicSerialization:
assert success is True
assert error is None
assert os.path.exists(zip_path)
assert zip_path.endswith('.ppz')
assert zip_path.endswith(".ppz")
def test_save_adds_ppz_extension(self, sample_project, temp_dir):
"""Test that .ppz extension is added automatically"""
@ -68,7 +68,7 @@ class TestBasicSerialization:
success, error = save_to_zip(sample_project, zip_path)
assert success is True
expected_path = zip_path + '.ppz'
expected_path = zip_path + ".ppz"
assert os.path.exists(expected_path)
def test_load_empty_project(self, sample_project, temp_dir):
@ -78,7 +78,6 @@ class TestBasicSerialization:
loaded_project = load_from_zip(zip_path)
assert loaded_project is not None
assert loaded_project.name == "Test Project"
assert loaded_project.page_size_mm == (210, 297)
@ -101,7 +100,7 @@ class TestBasicSerialization:
# Add pages
for i in range(3):
layout = PageLayout()
page = Page(layout=layout, page_number=i+1)
page = Page(layout=layout, page_number=i + 1)
sample_project.add_page(page)
zip_path = os.path.join(temp_dir, "project_with_pages.ppz")
@ -115,7 +114,7 @@ class TestBasicSerialization:
# Add pages
for i in range(3):
layout = PageLayout()
page = Page(layout=layout, page_number=i+1)
page = Page(layout=layout, page_number=i + 1)
sample_project.add_page(page)
# Save and load
@ -137,33 +136,33 @@ class TestZipStructure:
zip_path = os.path.join(temp_dir, "test.ppz")
save_to_zip(sample_project, zip_path)
with zipfile.ZipFile(zip_path, 'r') as zipf:
assert 'project.json' in zipf.namelist()
with zipfile.ZipFile(zip_path, "r") as zipf:
assert "project.json" in zipf.namelist()
def test_project_json_is_valid(self, sample_project, temp_dir):
"""Test that project.json contains valid JSON"""
zip_path = os.path.join(temp_dir, "test.ppz")
save_to_zip(sample_project, zip_path)
with zipfile.ZipFile(zip_path, 'r') as zipf:
project_json = zipf.read('project.json').decode('utf-8')
with zipfile.ZipFile(zip_path, "r") as zipf:
project_json = zipf.read("project.json").decode("utf-8")
data = json.loads(project_json)
assert 'name' in data
assert 'serialization_version' in data
assert data['name'] == "Test Project"
assert "name" in data
assert "serialization_version" in data
assert data["name"] == "Test Project"
def test_version_in_serialized_data(self, sample_project, temp_dir):
"""Test that version information is included"""
zip_path = os.path.join(temp_dir, "test.ppz")
save_to_zip(sample_project, zip_path)
with zipfile.ZipFile(zip_path, 'r') as zipf:
project_json = zipf.read('project.json').decode('utf-8')
with zipfile.ZipFile(zip_path, "r") as zipf:
project_json = zipf.read("project.json").decode("utf-8")
data = json.loads(project_json)
assert 'serialization_version' in data
assert data['serialization_version'] == "3.0"
assert "serialization_version" in data
assert data["serialization_version"] == "3.0"
class TestAssetManagement:
@ -205,10 +204,10 @@ class TestAssetManagement:
save_to_zip(sample_project, zip_path)
# Check ZIP contents
with zipfile.ZipFile(zip_path, 'r') as zipf:
with zipfile.ZipFile(zip_path, "r") as zipf:
names = zipf.namelist()
# Should contain assets folder
asset_files = [n for n in names if n.startswith('assets/')]
asset_files = [n for n in names if n.startswith("assets/")]
assert len(asset_files) > 0
def test_load_project_with_image(self, sample_project, sample_image, temp_dir):
@ -337,7 +336,7 @@ class TestProjectInfo:
# Add some pages
for i in range(5):
layout = PageLayout()
page = Page(layout=layout, page_number=i+1)
page = Page(layout=layout, page_number=i + 1)
sample_project.add_page(page)
# Save
@ -348,10 +347,10 @@ class TestProjectInfo:
info = get_project_info(zip_path)
assert info is not None
assert info['name'] == "Test Project"
assert info['page_count'] == 5
assert info['version'] == "3.0"
assert info['working_dpi'] == 300
assert info["name"] == "Test Project"
assert info["page_count"] == 5
assert info["version"] == "3.0"
assert info["working_dpi"] == 300
def test_get_info_invalid_zip(self, temp_dir):
"""Test getting info from invalid ZIP"""
@ -378,21 +377,17 @@ class TestEdgeCases:
"""Test loading a corrupted ZIP file"""
# Create a fake corrupted file
corrupted_path = os.path.join(temp_dir, "corrupted.ppz")
with open(corrupted_path, 'w') as f:
with open(corrupted_path, "w") as f:
f.write("This is not a ZIP file")
try:
loaded_project = load_from_zip(corrupted_path)
assert False, "Should have raised an exception"
except Exception as error:
assert error is not None
def test_load_zip_without_project_json(self, temp_dir):
@ -400,8 +395,8 @@ class TestEdgeCases:
zip_path = os.path.join(temp_dir, "no_json.ppz")
# Create ZIP without project.json
with zipfile.ZipFile(zip_path, 'w') as zipf:
zipf.writestr('dummy.txt', 'dummy content')
with zipfile.ZipFile(zip_path, "w") as zipf:
zipf.writestr("dummy.txt", "dummy content")
try:
loaded_project = load_from_zip(zip_path)
@ -414,10 +409,7 @@ class TestEdgeCases:
"""Test saving/loading project with text elements"""
# Create page with text
layout = PageLayout()
text = TextBoxData(
text_content="Hello World",
x=10, y=10, width=200, height=50
)
text = TextBoxData(text_content="Hello World", x=10, y=10, width=200, height=50)
layout.add_element(text)
page = Page(layout=layout, page_number=1)
sample_project.add_page(page)

View File

@ -0,0 +1,432 @@
"""
Comprehensive tests for project_serializer module
"""
import pytest
import os
import json
import zipfile
import tempfile
import shutil
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
from pyPhotoAlbum.project_serializer import (
save_to_zip,
load_from_zip,
get_project_info,
_normalize_asset_paths,
_import_external_images,
SERIALIZATION_VERSION,
)
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.models import ImageData
class TestSaveToZip:
"""Tests for save_to_zip function"""
def test_save_to_zip_basic(self, tmp_path):
"""Test basic project saving to zip"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TestProject", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
success, error = save_to_zip(project, zip_path)
assert success is True
assert error is None
assert os.path.exists(zip_path)
def test_save_to_zip_adds_extension(self, tmp_path):
"""Test that .ppz extension is added if missing"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TestProject", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project") # No extension
success, error = save_to_zip(project, zip_path)
assert success is True
assert os.path.exists(zip_path + ".ppz")
def test_save_to_zip_includes_project_json(self, tmp_path):
"""Test that saved zip contains project.json"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TestProject", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
with zipfile.ZipFile(zip_path, "r") as zipf:
assert "project.json" in zipf.namelist()
project_data = json.loads(zipf.read("project.json"))
assert project_data["name"] == "TestProject"
assert "data_version" in project_data
def test_save_to_zip_includes_assets(self, tmp_path):
"""Test that saved zip includes asset files"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
# Create a dummy asset file
asset_file = assets_folder / "image.jpg"
asset_file.write_bytes(b"fake image data")
project = Project(name="TestProject", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
with zipfile.ZipFile(zip_path, "r") as zipf:
assert "assets/image.jpg" in zipf.namelist()
def test_save_to_zip_handles_error(self, tmp_path):
"""Test error handling during save"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TestProject", folder_path=str(project_folder))
# Try to save to an invalid path
zip_path = "/nonexistent/directory/test.ppz"
success, error = save_to_zip(project, zip_path)
assert success is False
assert error is not None
assert "Error saving" in error
class TestLoadFromZip:
"""Tests for load_from_zip function"""
def test_load_from_zip_basic(self, tmp_path):
"""Test basic project loading from zip"""
# First create a valid project zip
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="LoadTest", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
# Now load it
extract_to = str(tmp_path / "extracted")
loaded_project = load_from_zip(zip_path, extract_to)
assert loaded_project.name == "LoadTest"
assert loaded_project.folder_path == extract_to
def test_load_from_zip_creates_temp_dir(self, tmp_path):
"""Test that loading creates a temp directory when none specified"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TempTest", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
# Load without specifying extraction directory
loaded_project = load_from_zip(zip_path)
assert loaded_project.name == "TempTest"
assert loaded_project.folder_path is not None
assert os.path.exists(loaded_project.folder_path)
# Should have a _temp_dir attribute
assert hasattr(loaded_project, "_temp_dir")
def test_load_from_zip_file_not_found(self, tmp_path):
"""Test loading from nonexistent file"""
with pytest.raises(FileNotFoundError):
load_from_zip(str(tmp_path / "nonexistent.ppz"))
def test_load_from_zip_invalid_zip(self, tmp_path):
"""Test loading from invalid zip file"""
invalid_file = tmp_path / "invalid.ppz"
invalid_file.write_text("not a zip file")
with pytest.raises(Exception):
load_from_zip(str(invalid_file))
def test_load_from_zip_missing_project_json(self, tmp_path):
"""Test loading from zip without project.json"""
zip_path = tmp_path / "no_project.ppz"
# Create zip without project.json
with zipfile.ZipFile(str(zip_path), "w") as zipf:
zipf.writestr("other_file.txt", "some content")
with pytest.raises(ValueError) as exc_info:
load_from_zip(str(zip_path))
assert "project.json not found" in str(exc_info.value)
class TestGetProjectInfo:
"""Tests for get_project_info function"""
def test_get_project_info_basic(self, tmp_path):
"""Test getting project info from zip"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="InfoTest", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
info = get_project_info(zip_path)
assert info is not None
assert info["name"] == "InfoTest"
assert "version" in info
assert "page_count" in info
assert "page_size_mm" in info
assert "working_dpi" in info
def test_get_project_info_invalid_file(self, tmp_path):
"""Test getting info from invalid file"""
invalid_file = tmp_path / "invalid.ppz"
invalid_file.write_text("not a zip")
info = get_project_info(str(invalid_file))
assert info is None
def test_get_project_info_nonexistent_file(self, tmp_path):
"""Test getting info from nonexistent file"""
info = get_project_info(str(tmp_path / "nonexistent.ppz"))
assert info is None
class TestNormalizeAssetPaths:
"""Tests for _normalize_asset_paths function"""
def test_normalize_relative_path_unchanged(self, tmp_path):
"""Test that simple relative paths are unchanged"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Test", folder_path=str(project_folder))
# Add a page with an image that has a simple relative path
from pyPhotoAlbum.page_layout import PageLayout
page_mock = Mock()
layout = PageLayout(width=210, height=297)
img = ImageData(image_path="assets/image.jpg")
layout.add_element(img)
page_mock.layout = layout
project.pages = [page_mock]
_normalize_asset_paths(project, str(project_folder))
# Path should be unchanged
assert img.image_path == "assets/image.jpg"
def test_normalize_absolute_path(self, tmp_path):
"""Test that absolute paths are normalized"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Test", folder_path=str(project_folder))
from pyPhotoAlbum.page_layout import PageLayout
page_mock = Mock()
layout = PageLayout(width=210, height=297)
# Use a path that contains /assets/ pattern
abs_path = str(project_folder / "assets" / "image.jpg")
img = ImageData(image_path=abs_path)
layout.add_element(img)
page_mock.layout = layout
project.pages = [page_mock]
_normalize_asset_paths(project, str(project_folder))
# Path should be normalized to relative
assert img.image_path == "assets/image.jpg"
def test_normalize_legacy_path(self, tmp_path):
"""Test normalizing legacy project path format"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Test", folder_path=str(project_folder))
from pyPhotoAlbum.page_layout import PageLayout
page_mock = Mock()
layout = PageLayout(width=210, height=297)
# Legacy path format
img = ImageData(image_path="./projects/old_project/assets/image.jpg")
layout.add_element(img)
page_mock.layout = layout
project.pages = [page_mock]
_normalize_asset_paths(project, str(project_folder))
# Should extract just the assets/filename part
assert img.image_path == "assets/image.jpg"
class TestImportExternalImages:
"""Tests for _import_external_images function"""
def test_import_external_images_no_external(self, tmp_path):
"""Test with no external images"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Test", folder_path=str(project_folder))
from pyPhotoAlbum.page_layout import PageLayout
page_mock = Mock()
layout = PageLayout(width=210, height=297)
img = ImageData(image_path="assets/existing.jpg")
layout.add_element(img)
page_mock.layout = layout
project.pages = [page_mock]
# Should not raise and not change path
_import_external_images(project)
assert img.image_path == "assets/existing.jpg"
class TestRoundTrip:
"""Test save and load roundtrip"""
def test_roundtrip_basic(self, tmp_path):
"""Test saving and loading a project"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
original = Project(name="RoundTrip", folder_path=str(project_folder))
original.working_dpi = 150
zip_path = str(tmp_path / "roundtrip.ppz")
success, _ = save_to_zip(original, zip_path)
assert success
extract_to = str(tmp_path / "extracted")
loaded = load_from_zip(zip_path, extract_to)
assert loaded.name == original.name
assert loaded.working_dpi == original.working_dpi
def test_roundtrip_with_pages(self, tmp_path):
"""Test roundtrip with pages"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
original = Project(name="WithPages", folder_path=str(project_folder))
# Project starts with 1 page, add more using create_page
from pyPhotoAlbum.project import Page
from pyPhotoAlbum.page_layout import PageLayout
page2 = Page(PageLayout(width=210, height=297))
page3 = Page(PageLayout(width=210, height=297))
original.add_page(page2)
original.add_page(page3)
zip_path = str(tmp_path / "pages.ppz")
save_to_zip(original, zip_path)
extract_to = str(tmp_path / "extracted")
loaded = load_from_zip(zip_path, extract_to)
# Pages are preserved (Project might not start with a default page)
assert len(loaded.pages) >= 2
def test_roundtrip_with_elements(self, tmp_path, temp_image_file):
"""Test roundtrip with elements on page"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
# Copy temp image to assets
shutil.copy(temp_image_file, assets_folder / "test.jpg")
original = Project(name="WithElements", folder_path=str(project_folder))
# Add element to first page (project starts with at least 1 page)
img = ImageData(image_path="assets/test.jpg", x=50, y=50, width=100, height=100)
# Check if there's a default page, add one if needed
if not original.pages:
from pyPhotoAlbum.project import Page
from pyPhotoAlbum.page_layout import PageLayout
original.add_page(Page(PageLayout(width=210, height=297)))
original.pages[0].layout.add_element(img)
zip_path = str(tmp_path / "elements.ppz")
save_to_zip(original, zip_path)
extract_to = str(tmp_path / "extracted")
loaded = load_from_zip(zip_path, extract_to)
assert len(loaded.pages) >= 1
assert len(loaded.pages[0].layout.elements) >= 1
loaded_elem = loaded.pages[0].layout.elements[0]
assert loaded_elem.position == (50.0, 50.0)
assert loaded_elem.size == (100.0, 100.0)
class TestVersionCompatibility:
"""Tests for version handling"""
def test_version_included_in_save(self, tmp_path):
"""Test that version is included when saving"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Version", folder_path=str(project_folder))
zip_path = str(tmp_path / "version.ppz")
save_to_zip(project, zip_path)
with zipfile.ZipFile(zip_path, "r") as zipf:
data = json.loads(zipf.read("project.json"))
# Should have both legacy and new version fields
assert "serialization_version" in data
assert "data_version" in data

View File

@ -0,0 +1,634 @@
"""
Tests for ribbon_builder module
"""
import pytest
from io import StringIO
from unittest.mock import Mock, patch
from pyPhotoAlbum.ribbon_builder import (
build_ribbon_config,
get_keyboard_shortcuts,
validate_ribbon_config,
print_ribbon_summary,
)
class TestBuildRibbonConfig:
"""Tests for build_ribbon_config function"""
def test_empty_class(self):
"""Test with a class that has no ribbon actions"""
class EmptyClass:
pass
config = build_ribbon_config(EmptyClass)
assert config == {}
def test_single_action(self):
"""Test with a class that has one ribbon action"""
class SingleAction:
def my_action(self):
pass
my_action._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "My Action",
"action": "my_action",
"tooltip": "Does something",
}
config = build_ribbon_config(SingleAction)
assert "Home" in config
assert len(config["Home"]["groups"]) == 1
assert config["Home"]["groups"][0]["name"] == "File"
assert len(config["Home"]["groups"][0]["actions"]) == 1
assert config["Home"]["groups"][0]["actions"][0]["label"] == "My Action"
def test_multiple_actions_same_group(self):
"""Test with multiple actions in the same group"""
class MultiAction:
def action1(self):
pass
action1._ribbon_action = {
"tab": "Home",
"group": "Edit",
"label": "Action 1",
"action": "action1",
"tooltip": "First action",
}
def action2(self):
pass
action2._ribbon_action = {
"tab": "Home",
"group": "Edit",
"label": "Action 2",
"action": "action2",
"tooltip": "Second action",
}
config = build_ribbon_config(MultiAction)
assert "Home" in config
assert len(config["Home"]["groups"]) == 1
assert config["Home"]["groups"][0]["name"] == "Edit"
assert len(config["Home"]["groups"][0]["actions"]) == 2
def test_multiple_groups(self):
"""Test with actions in different groups"""
class MultiGroup:
def action1(self):
pass
action1._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "File Action",
"action": "action1",
"tooltip": "File stuff",
}
def action2(self):
pass
action2._ribbon_action = {
"tab": "Home",
"group": "Edit",
"label": "Edit Action",
"action": "action2",
"tooltip": "Edit stuff",
}
config = build_ribbon_config(MultiGroup)
assert "Home" in config
assert len(config["Home"]["groups"]) == 2
group_names = [g["name"] for g in config["Home"]["groups"]]
assert "File" in group_names
assert "Edit" in group_names
def test_multiple_tabs(self):
"""Test with actions in different tabs"""
class MultiTab:
def action1(self):
pass
action1._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Home Action",
"action": "action1",
"tooltip": "Home stuff",
}
def action2(self):
pass
action2._ribbon_action = {
"tab": "View",
"group": "Zoom",
"label": "View Action",
"action": "action2",
"tooltip": "View stuff",
}
config = build_ribbon_config(MultiTab)
assert "Home" in config
assert "View" in config
def test_tab_ordering(self):
"""Test that tabs are ordered correctly"""
class OrderedTabs:
def action1(self):
pass
action1._ribbon_action = {
"tab": "Export",
"group": "Export",
"label": "Export",
"action": "action1",
"tooltip": "Export",
}
def action2(self):
pass
action2._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Home",
"action": "action2",
"tooltip": "Home",
}
def action3(self):
pass
action3._ribbon_action = {
"tab": "View",
"group": "Zoom",
"label": "View",
"action": "action3",
"tooltip": "View",
}
config = build_ribbon_config(OrderedTabs)
tab_names = list(config.keys())
# Home should come before View, View before Export
assert tab_names.index("Home") < tab_names.index("View")
assert tab_names.index("View") < tab_names.index("Export")
def test_action_with_optional_fields(self):
"""Test action with optional icon and shortcut"""
class WithOptional:
def action(self):
pass
action._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Save",
"action": "save",
"tooltip": "Save project",
"icon": "save.png",
"shortcut": "Ctrl+S",
}
config = build_ribbon_config(WithOptional)
action = config["Home"]["groups"][0]["actions"][0]
assert action["icon"] == "save.png"
assert action["shortcut"] == "Ctrl+S"
def test_action_without_optional_fields(self):
"""Test action without optional icon and shortcut"""
class WithoutOptional:
def action(self):
pass
action._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Action",
"action": "action",
"tooltip": "Does stuff",
}
config = build_ribbon_config(WithoutOptional)
action = config["Home"]["groups"][0]["actions"][0]
assert action.get("icon") is None
assert action.get("shortcut") is None
def test_custom_tab_not_in_order(self):
"""Test custom tab not in predefined order"""
class CustomTab:
def action(self):
pass
action._ribbon_action = {
"tab": "CustomTab",
"group": "CustomGroup",
"label": "Custom",
"action": "action",
"tooltip": "Custom action",
}
config = build_ribbon_config(CustomTab)
assert "CustomTab" in config
def test_inherited_actions(self):
"""Test that actions from parent classes are included"""
class BaseClass:
def base_action(self):
pass
base_action._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Base Action",
"action": "base_action",
"tooltip": "From base",
}
class DerivedClass(BaseClass):
def derived_action(self):
pass
derived_action._ribbon_action = {
"tab": "Home",
"group": "Edit",
"label": "Derived Action",
"action": "derived_action",
"tooltip": "From derived",
}
config = build_ribbon_config(DerivedClass)
# Should have both actions
all_actions = []
for group in config["Home"]["groups"]:
all_actions.extend(group["actions"])
action_names = [a["action"] for a in all_actions]
assert "base_action" in action_names
assert "derived_action" in action_names
class TestGetKeyboardShortcuts:
"""Tests for get_keyboard_shortcuts function"""
def test_empty_class(self):
"""Test with a class that has no shortcuts"""
class NoShortcuts:
pass
shortcuts = get_keyboard_shortcuts(NoShortcuts)
assert shortcuts == {}
def test_single_shortcut(self):
"""Test with a single shortcut"""
class SingleShortcut:
def save(self):
pass
save._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Save",
"action": "save",
"tooltip": "Save",
"shortcut": "Ctrl+S",
}
shortcuts = get_keyboard_shortcuts(SingleShortcut)
assert "Ctrl+S" in shortcuts
assert shortcuts["Ctrl+S"] == "save"
def test_multiple_shortcuts(self):
"""Test with multiple shortcuts"""
class MultiShortcut:
def save(self):
pass
save._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Save",
"action": "save",
"tooltip": "Save",
"shortcut": "Ctrl+S",
}
def undo(self):
pass
undo._ribbon_action = {
"tab": "Home",
"group": "Edit",
"label": "Undo",
"action": "undo",
"tooltip": "Undo",
"shortcut": "Ctrl+Z",
}
shortcuts = get_keyboard_shortcuts(MultiShortcut)
assert len(shortcuts) == 2
assert shortcuts["Ctrl+S"] == "save"
assert shortcuts["Ctrl+Z"] == "undo"
def test_action_without_shortcut_ignored(self):
"""Test that actions without shortcuts are not included"""
class MixedShortcuts:
def with_shortcut(self):
pass
with_shortcut._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "With",
"action": "with_shortcut",
"tooltip": "Has shortcut",
"shortcut": "Ctrl+W",
}
def without_shortcut(self):
pass
without_shortcut._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Without",
"action": "without_shortcut",
"tooltip": "No shortcut",
}
shortcuts = get_keyboard_shortcuts(MixedShortcuts)
assert len(shortcuts) == 1
assert "Ctrl+W" in shortcuts
class TestValidateRibbonConfig:
"""Tests for validate_ribbon_config function"""
def test_valid_config(self):
"""Test with a valid configuration"""
config = {
"Home": {
"groups": [
{
"name": "File",
"actions": [
{
"label": "Save",
"action": "save",
"tooltip": "Save project",
}
],
}
]
}
}
errors = validate_ribbon_config(config)
assert errors == []
def test_empty_config(self):
"""Test with empty config"""
errors = validate_ribbon_config({})
assert errors == []
def test_config_not_dict(self):
"""Test with non-dict config"""
errors = validate_ribbon_config("not a dict")
assert len(errors) == 1
assert "must be a dictionary" in errors[0]
def test_tab_data_not_dict(self):
"""Test with tab data that is not a dict"""
config = {"Home": "not a dict"}
errors = validate_ribbon_config(config)
assert len(errors) == 1
assert "Tab 'Home' data must be a dictionary" in errors[0]
def test_missing_groups_key(self):
"""Test with missing 'groups' key"""
config = {"Home": {"other_key": []}}
errors = validate_ribbon_config(config)
assert len(errors) == 1
assert "missing 'groups' key" in errors[0]
def test_groups_not_list(self):
"""Test with groups that is not a list"""
config = {"Home": {"groups": "not a list"}}
errors = validate_ribbon_config(config)
assert len(errors) == 1
assert "groups must be a list" in errors[0]
def test_group_not_dict(self):
"""Test with group that is not a dict"""
config = {"Home": {"groups": ["not a dict"]}}
errors = validate_ribbon_config(config)
assert len(errors) == 1
assert "group 0 must be a dictionary" in errors[0]
def test_group_missing_name(self):
"""Test with group missing name"""
config = {"Home": {"groups": [{"actions": []}]}}
errors = validate_ribbon_config(config)
assert any("missing 'name'" in e for e in errors)
def test_group_missing_actions(self):
"""Test with group missing actions"""
config = {"Home": {"groups": [{"name": "File"}]}}
errors = validate_ribbon_config(config)
assert any("missing 'actions'" in e for e in errors)
def test_actions_not_list(self):
"""Test with actions that is not a list"""
config = {"Home": {"groups": [{"name": "File", "actions": "not a list"}]}}
errors = validate_ribbon_config(config)
assert any("actions must be a list" in e for e in errors)
def test_action_not_dict(self):
"""Test with action that is not a dict"""
config = {"Home": {"groups": [{"name": "File", "actions": ["not a dict"]}]}}
errors = validate_ribbon_config(config)
assert any("action 0 must be a dictionary" in e for e in errors)
def test_action_missing_required_keys(self):
"""Test with action missing required keys"""
config = {
"Home": {
"groups": [
{
"name": "File",
"actions": [
{
"label": "Save"
# missing 'action' and 'tooltip'
}
],
}
]
}
}
errors = validate_ribbon_config(config)
assert any("missing 'action'" in e for e in errors)
assert any("missing 'tooltip'" in e for e in errors)
def test_multiple_errors(self):
"""Test that multiple errors are collected"""
config = {
"Tab1": {"groups": [{"name": "Group1", "actions": [{"label": "A"}]}]}, # missing action and tooltip
"Tab2": {"groups": "not a list"},
}
errors = validate_ribbon_config(config)
assert len(errors) >= 3 # At least: missing action, missing tooltip, groups not list
class TestPrintRibbonSummary:
"""Tests for print_ribbon_summary function"""
def test_print_empty_config(self):
"""Test printing empty config"""
config = {}
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
print_ribbon_summary(config)
output = mock_stdout.getvalue()
assert "Total Tabs: 0" in output
assert "Total Groups: 0" in output
assert "Total Actions: 0" in output
def test_print_single_tab(self):
"""Test printing single tab config"""
config = {
"Home": {
"groups": [
{
"name": "File",
"actions": [
{
"label": "Save",
"action": "save",
"tooltip": "Save",
}
],
}
]
}
}
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
print_ribbon_summary(config)
output = mock_stdout.getvalue()
assert "Total Tabs: 1" in output
assert "Total Groups: 1" in output
assert "Total Actions: 1" in output
assert "Home" in output
assert "File" in output
assert "Save" in output
def test_print_with_shortcuts(self):
"""Test printing actions with shortcuts"""
config = {
"Home": {
"groups": [
{
"name": "File",
"actions": [
{
"label": "Save",
"action": "save",
"tooltip": "Save",
"shortcut": "Ctrl+S",
}
],
}
]
}
}
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
print_ribbon_summary(config)
output = mock_stdout.getvalue()
assert "(Ctrl+S)" in output
def test_print_multiple_tabs_and_groups(self):
"""Test printing config with multiple tabs and groups"""
config = {
"Home": {
"groups": [
{
"name": "File",
"actions": [
{"label": "New", "action": "new", "tooltip": "New"},
{"label": "Open", "action": "open", "tooltip": "Open"},
],
},
{
"name": "Edit",
"actions": [
{"label": "Undo", "action": "undo", "tooltip": "Undo"},
],
},
]
},
"View": {
"groups": [
{
"name": "Zoom",
"actions": [
{"label": "Zoom In", "action": "zoom_in", "tooltip": "Zoom In"},
],
}
]
},
}
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
print_ribbon_summary(config)
output = mock_stdout.getvalue()
assert "Total Tabs: 2" in output
assert "Total Groups: 3" in output
assert "Total Actions: 4" in output

402
tests/test_ribbon_widget.py Normal file
View File

@ -0,0 +1,402 @@
"""
Tests for ribbon_widget module
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
class TestRibbonWidgetInit:
"""Tests for RibbonWidget initialization"""
def test_init_with_custom_config(self, qtbot):
"""Test initialization with custom ribbon config"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {
"File": {
"groups": [
{"name": "Project", "actions": [{"label": "New", "action": "new_project", "tooltip": "Create new"}]}
]
}
}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
assert widget.main_window == mock_main_window
assert widget.ribbon_config == config
assert widget.buttons_per_row == 4 # default
def test_init_with_custom_buttons_per_row(self, qtbot):
"""Test initialization with custom buttons_per_row"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {"Test": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config, buttons_per_row=6)
qtbot.addWidget(widget)
assert widget.buttons_per_row == 6
def test_init_creates_tab_widget(self, qtbot):
"""Test that initialization creates a tab widget"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {"Tab1": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
assert widget.tab_widget is not None
assert widget.tab_widget.count() == 1
class TestBuildRibbon:
"""Tests for _build_ribbon method"""
def test_build_ribbon_creates_tabs(self, qtbot):
"""Test that _build_ribbon creates tabs from config"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {"File": {"groups": []}, "Edit": {"groups": []}, "View": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
assert widget.tab_widget.count() == 3
# Tab names should be present
tab_names = [widget.tab_widget.tabText(i) for i in range(widget.tab_widget.count())]
assert "File" in tab_names
assert "Edit" in tab_names
assert "View" in tab_names
def test_build_ribbon_empty_config(self, qtbot):
"""Test _build_ribbon with empty config"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
assert widget.tab_widget.count() == 0
class TestCreateTab:
"""Tests for _create_tab method"""
def test_create_tab_with_groups(self, qtbot):
"""Test tab creation with groups"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {"Test": {"groups": [{"name": "Group1", "actions": []}, {"name": "Group2", "actions": []}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
# Get the tab widget content
tab_content = widget.tab_widget.widget(0)
assert tab_content is not None
def test_create_tab_empty_groups(self, qtbot):
"""Test tab creation with no groups"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {"Test": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
tab_content = widget.tab_widget.widget(0)
assert tab_content is not None
class TestCreateGroup:
"""Tests for _create_group method"""
def test_create_group_with_actions(self, qtbot):
"""Test group creation with action buttons"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {
"Test": {
"groups": [
{
"name": "Actions",
"actions": [
{"label": "Action1", "action": "do_action1"},
{"label": "Action2", "action": "do_action2"},
],
}
]
}
}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
tab_content = widget.tab_widget.widget(0)
# Find buttons in the tab
buttons = tab_content.findChildren(QPushButton)
assert len(buttons) == 2
button_labels = [btn.text() for btn in buttons]
assert "Action1" in button_labels
assert "Action2" in button_labels
def test_create_group_respects_buttons_per_row(self, qtbot):
"""Test that group respects buttons_per_row from config"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {
"Test": {
"groups": [
{
"name": "Grid",
"buttons_per_row": 2,
"actions": [
{"label": "A", "action": "a"},
{"label": "B", "action": "b"},
{"label": "C", "action": "c"},
{"label": "D", "action": "d"},
],
}
]
}
}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
tab_content = widget.tab_widget.widget(0)
buttons = tab_content.findChildren(QPushButton)
assert len(buttons) == 4
class TestCreateActionButton:
"""Tests for _create_action_button method"""
def test_button_has_correct_label(self, qtbot):
"""Test that button has correct label"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {"Test": {"groups": [{"name": "Test", "actions": [{"label": "My Button", "action": "my_action"}]}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
buttons = widget.tab_widget.widget(0).findChildren(QPushButton)
assert len(buttons) == 1
assert buttons[0].text() == "My Button"
def test_button_has_tooltip(self, qtbot):
"""Test that button has correct tooltip"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {
"Test": {
"groups": [
{"name": "Test", "actions": [{"label": "Button", "action": "action", "tooltip": "My tooltip"}]}
]
}
}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
buttons = widget.tab_widget.widget(0).findChildren(QPushButton)
assert buttons[0].toolTip() == "My tooltip"
def test_button_without_tooltip(self, qtbot):
"""Test button without tooltip configured"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {"Test": {"groups": [{"name": "Test", "actions": [{"label": "Button", "action": "action"}]}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
buttons = widget.tab_widget.widget(0).findChildren(QPushButton)
assert buttons[0].toolTip() == ""
def test_button_minimum_size(self, qtbot):
"""Test that button has minimum size set"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {"Test": {"groups": [{"name": "Test", "actions": [{"label": "Button", "action": "action"}]}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
buttons = widget.tab_widget.widget(0).findChildren(QPushButton)
assert buttons[0].minimumWidth() == 60
assert buttons[0].minimumHeight() == 40
class TestExecuteAction:
"""Tests for _execute_action method"""
def test_execute_action_calls_main_window_method(self, qtbot):
"""Test that _execute_action calls the method on main_window"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
mock_main_window.my_action = Mock()
config = {"Test": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
widget._execute_action("my_action")
mock_main_window.my_action.assert_called_once()
def test_execute_action_missing_method_prints_warning(self, qtbot, capsys):
"""Test that _execute_action prints warning for missing method"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock(spec=[]) # No methods
config = {"Test": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
widget._execute_action("nonexistent_action")
captured = capsys.readouterr()
assert "Warning" in captured.out
assert "nonexistent_action" in captured.out
def test_execute_action_non_callable_not_called(self, qtbot):
"""Test that non-callable attributes are not called"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
mock_main_window.not_a_method = "just a string"
config = {"Test": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
# Should not raise
widget._execute_action("not_a_method")
def test_button_click_executes_action(self, qtbot):
"""Test that clicking a button executes the action"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
mock_main_window.do_something = Mock()
config = {"Test": {"groups": [{"name": "Test", "actions": [{"label": "Do It", "action": "do_something"}]}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
# Find the button and click it
buttons = widget.tab_widget.widget(0).findChildren(QPushButton)
assert len(buttons) == 1
qtbot.mouseClick(buttons[0], Qt.MouseButton.LeftButton)
mock_main_window.do_something.assert_called_once()
class TestGroupLabel:
"""Tests for group label creation"""
def test_group_has_label(self, qtbot):
"""Test that group has a label"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QLabel
mock_main_window = Mock()
config = {"Test": {"groups": [{"name": "My Group", "actions": []}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
tab_content = widget.tab_widget.widget(0)
labels = tab_content.findChildren(QLabel)
# Should have at least one label with the group name
label_texts = [lbl.text() for lbl in labels]
assert "My Group" in label_texts
class TestRibbonLayoutIntegration:
"""Integration tests for ribbon layout"""
def test_full_ribbon_structure(self, qtbot):
"""Test complete ribbon structure with multiple tabs and groups"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {
"File": {
"groups": [
{
"name": "Project",
"actions": [
{"label": "New", "action": "new_project"},
{"label": "Open", "action": "open_project"},
{"label": "Save", "action": "save_project"},
],
},
{"name": "Export", "actions": [{"label": "Export PDF", "action": "export_pdf"}]},
]
},
"Edit": {
"groups": [
{
"name": "Clipboard",
"actions": [{"label": "Copy", "action": "copy"}, {"label": "Paste", "action": "paste"}],
}
]
},
}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
# Check tabs
assert widget.tab_widget.count() == 2
# Check File tab has 4 buttons
file_tab = widget.tab_widget.widget(0)
file_buttons = file_tab.findChildren(QPushButton)
assert len(file_buttons) == 4
# Check Edit tab has 2 buttons
edit_tab = widget.tab_widget.widget(1)
edit_buttons = edit_tab.findChildren(QPushButton)
assert len(edit_buttons) == 2
# Import Qt for click simulation
from PyQt6.QtCore import Qt

View File

@ -19,7 +19,7 @@ class TestRotationSerialization:
def sample_image(self):
"""Create a sample test image"""
# Create a 400x200 test image (wider than tall)
img = Image.new('RGBA', (400, 200), color=(255, 0, 0, 255))
img = Image.new("RGBA", (400, 200), color=(255, 0, 0, 255))
return img
def test_serialize_rotation_metadata(self):
@ -47,7 +47,7 @@ class TestRotationSerialization:
"image_path": "test.jpg",
"crop_info": (0, 0, 1, 1),
"pil_rotation_90": 1,
"image_dimensions": (400, 200)
"image_dimensions": (400, 200),
}
img_data = ImageData()
@ -71,8 +71,10 @@ class TestRotationSerialization:
img_data._on_async_image_loaded(sample_image)
# Verify dimensions are updated to rotated dimensions
assert img_data.image_dimensions == (200, 400), \
f"Expected rotated dimensions (200, 400), got {img_data.image_dimensions}"
assert img_data.image_dimensions == (
200,
400,
), f"Expected rotated dimensions (200, 400), got {img_data.image_dimensions}"
assert img_data._img_width == 200
assert img_data._img_height == 400
@ -143,7 +145,7 @@ class TestRotationSerialization:
"image_path": "test.jpg",
"crop_info": (0, 0, 1, 1),
"pil_rotation_90": 0, # Not set in old format
"image_dimensions": (400, 200)
"image_dimensions": (400, 200),
}
img_data = ImageData()
@ -182,7 +184,6 @@ class TestRotationSerialization:
img2._on_async_image_loaded(sample_image)
# Verify dimensions are STILL correct after reload
assert img2.image_dimensions == (200, 400), \
"Dimensions should remain correct after reload"
assert img2.image_dimensions == (200, 400), "Dimensions should remain correct after reload"
assert img2._img_width == 200
assert img2._img_height == 400

View File

@ -30,7 +30,7 @@ class TestSizeWindow(SizeOperationsMixin, QMainWindow):
return len(self.gl_widget.selected_elements) >= min_count
def get_current_page(self):
if hasattr(self, '_current_page'):
if hasattr(self, "_current_page"):
return self._current_page
return None
@ -47,7 +47,7 @@ class TestSizeWindow(SizeOperationsMixin, QMainWindow):
class TestMakeSameSize:
"""Test make_same_size method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_make_same_size_success(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)
@ -57,10 +57,7 @@ class TestMakeSameSize:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.make_same_size.return_value = [
(element1, (0, 0), (100, 100)),
(element2, (150, 0), (200, 150))
]
mock_manager.make_same_size.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))]
window.make_same_size()
@ -84,7 +81,7 @@ class TestMakeSameSize:
class TestMakeSameWidth:
"""Test make_same_width method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_make_same_width_success(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)
@ -94,10 +91,7 @@ class TestMakeSameWidth:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.make_same_width.return_value = [
(element1, (0, 0), (100, 100)),
(element2, (150, 0), (200, 150))
]
mock_manager.make_same_width.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))]
window.make_same_width()
@ -109,7 +103,7 @@ class TestMakeSameWidth:
class TestMakeSameHeight:
"""Test make_same_height method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_make_same_height_success(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)
@ -119,10 +113,7 @@ class TestMakeSameHeight:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.make_same_height.return_value = [
(element1, (0, 0), (100, 100)),
(element2, (150, 0), (200, 150))
]
mock_manager.make_same_height.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))]
window.make_same_height()
@ -134,7 +125,7 @@ class TestMakeSameHeight:
class TestFitToWidth:
"""Test fit_to_width method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_fit_to_width_success(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)
@ -174,7 +165,7 @@ class TestFitToWidth:
class TestFitToHeight:
"""Test fit_to_height method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_fit_to_height_success(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)
@ -199,7 +190,7 @@ class TestFitToHeight:
class TestFitToPage:
"""Test fit_to_page method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_fit_to_page_success(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)
@ -238,7 +229,7 @@ class TestFitToPage:
class TestSizeCommandPattern:
"""Test size operations with command pattern"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_size_operation_creates_command(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)
@ -248,10 +239,7 @@ class TestSizeCommandPattern:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.make_same_size.return_value = [
(element1, (0, 0), (100, 100)),
(element2, (150, 0), (200, 150))
]
mock_manager.make_same_size.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))]
assert not window.project.history.can_undo()
@ -259,7 +247,7 @@ class TestSizeCommandPattern:
assert window.project.history.can_undo()
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_fit_operation_creates_command(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)
@ -284,7 +272,7 @@ class TestSizeCommandPattern:
class TestExpandImage:
"""Test expand_image method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_expand_image_success(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)

Some files were not shown because too many files have changed in this diff Show More