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 # Calculate distances between rectangles
horizontal_gap = max( horizontal_gap = max(
other_x - (x + w), # Other is to the right other_x - (x + w), x - (other_x + other_w) # Other is to the right # Other is to the left
x - (other_x + other_w) # Other is to the left
) )
vertical_gap = max( vertical_gap = max(other_y - (y + h), y - (other_y + other_h)) # Other is below # Other is above
other_y - (y + h), # Other is below
y - (other_y + other_h) # Other is above
)
# If rectangles overlap or are too close in both dimensions # If rectangles overlap or are too close in both dimensions
if horizontal_gap < self.min_gap and vertical_gap < self.min_gap: if horizontal_gap < self.min_gap and vertical_gap < self.min_gap:
@ -80,8 +76,14 @@ class ElementMaximizer:
return False return False
def find_max_scale(self, elem_idx: int, current_scale: float, max_search_scale: float = 3.0, def find_max_scale(
tolerance: float = 0.001, max_iterations: int = 20) -> float: 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. Use binary search to find the maximum scale factor for an element.
@ -171,8 +173,10 @@ class ElementMaximizer:
ow, oh = other.size ow, oh = other.size
# Check if rectangles overlap (with min_gap consideration) # Check if rectangles overlap (with min_gap consideration)
if (abs((x + w/2) - (ox + ow/2)) < (w + ow)/2 + self.min_gap and if (
abs((y + h/2) - (oy + oh/2)) < (h + oh)/2 + self.min_gap): 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 True
return False return False
@ -233,7 +237,9 @@ class ElementMaximizer:
self.center_element_horizontally(elem) self.center_element_horizontally(elem)
self.center_element_vertically(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. Execute the maximization algorithm.
@ -365,7 +371,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @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. Align all elements to horizontal center.
@ -413,7 +421,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @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. Make all elements the same size as the first element.
@ -435,7 +445,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @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. Make all elements the same width as the first element.
@ -457,7 +469,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @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. Make all elements the same height as the first element.
@ -479,7 +493,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @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. Distribute elements evenly across horizontal span.
@ -613,7 +629,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @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. Resize element to fit page width while maintaining aspect ratio.
@ -638,7 +656,9 @@ class AlignmentManager:
return (element, old_pos, old_size) return (element, old_pos, old_size)
@staticmethod @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. Resize element to fit page height while maintaining aspect ratio.
@ -663,7 +683,9 @@ class AlignmentManager:
return (element, old_pos, old_size) return (element, old_pos, old_size)
@staticmethod @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. Resize element to fit within page dimensions while maintaining aspect ratio.
@ -702,7 +724,7 @@ class AlignmentManager:
page_size: Tuple[float, float], page_size: Tuple[float, float],
min_gap: float = 2.0, min_gap: float = 2.0,
max_iterations: int = 100, max_iterations: int = 100,
growth_rate: float = 0.05 growth_rate: float = 0.05,
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: ) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
""" """
Maximize element sizes using a crystal growth algorithm. Maximize element sizes using a crystal growth algorithm.
@ -729,7 +751,7 @@ class AlignmentManager:
element: BaseLayoutElement, element: BaseLayoutElement,
page_size: Tuple[float, float], page_size: Tuple[float, float],
other_elements: List[BaseLayoutElement], other_elements: List[BaseLayoutElement],
min_gap: float = 10.0 min_gap: float = 10.0,
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: ) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
""" """
Expand a single element until it is min_gap away from page edges or other elements. 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 import shutil
from typing import List, Dict, Set from typing import List, Dict, Set
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QDialog,
QListWidget, QListWidgetItem, QFileDialog, QGroupBox, QVBoxLayout,
QMessageBox QHBoxLayout,
QLabel,
QPushButton,
QListWidget,
QListWidgetItem,
QFileDialog,
QGroupBox,
QMessageBox,
) )
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
@ -124,10 +131,7 @@ class AssetHealDialog(QDialog):
def _add_search_path(self): def _add_search_path(self):
"""Add a search path""" """Add a search path"""
directory = QFileDialog.getExistingDirectory( directory = QFileDialog.getExistingDirectory(
self, self, "Select Search Path for Assets", "", QFileDialog.Option.ShowDirsOnly
"Select Search Path for Assets",
"",
QFileDialog.Option.ShowDirsOnly
) )
if directory: if directory:
@ -203,7 +207,7 @@ class AssetHealDialog(QDialog):
# Check if the found file needs to be imported # Check if the found file needs to be imported
# (i.e., it's not already in the assets folder) # (i.e., it's not already in the assets folder)
needs_import = True 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 # It's already a relative assets path, just missing from disk
# Copy it to the correct location # Copy it to the correct location
dest_path = os.path.join(self.project.folder_path, asset_path) dest_path = os.path.join(self.project.folder_path, asset_path)

View File

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

View File

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

View File

@ -18,7 +18,7 @@ from pyPhotoAlbum.version_manager import (
CURRENT_DATA_VERSION, CURRENT_DATA_VERSION,
check_version_compatibility, check_version_compatibility,
VersionCompatibility, VersionCompatibility,
DataMigration DataMigration,
) )
@ -78,7 +78,7 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(10, 100, "Extracting project files...") self.progress_updated.emit(10, 100, "Extracting project files...")
# Extract ZIP contents with progress # 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() file_list = zipf.namelist()
total_files = len(file_list) total_files = len(file_list)
@ -91,10 +91,7 @@ class AsyncProjectLoader(QThread):
# Update progress every 10 files or on last file # Update progress every 10 files or on last file
if i % 10 == 0 or i == total_files - 1: if i % 10 == 0 or i == total_files - 1:
progress = 10 + int((i / total_files) * 30) # 10-40% progress = 10 + int((i / total_files) * 30) # 10-40%
self.progress_updated.emit( self.progress_updated.emit(progress, 100, f"Extracting files... ({i + 1}/{total_files})")
progress, 100,
f"Extracting files... ({i + 1}/{total_files})"
)
if self._cancelled: if self._cancelled:
return return
@ -103,12 +100,12 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(45, 100, "Loading project data...") self.progress_updated.emit(45, 100, "Loading project data...")
# Load project.json # 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): if not os.path.exists(project_json_path):
self.load_failed.emit("Invalid project file: project.json not found") self.load_failed.emit("Invalid project file: project.json not found")
return return
with open(project_json_path, 'r') as f: with open(project_json_path, "r") as f:
project_data = json.load(f) project_data = json.load(f)
if self._cancelled: if self._cancelled:
@ -118,7 +115,7 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(55, 100, "Checking version compatibility...") self.progress_updated.emit(55, 100, "Checking version compatibility...")
# Check 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) is_compatible, error_msg = check_version_compatibility(file_version, self.zip_path)
if not is_compatible: if not is_compatible:
@ -141,7 +138,7 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(70, 100, "Creating project...") self.progress_updated.emit(70, 100, "Creating project...")
# Create new 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) project = Project(name=project_name, folder_path=extract_to)
# Deserialize project data # Deserialize project data
@ -197,14 +194,14 @@ class AsyncProjectLoader(QThread):
original_path = element.image_path original_path = element.image_path
# Skip if already a simple relative 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 continue
# Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg" # Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
if '/assets/' in original_path: if "/assets/" in original_path:
parts = original_path.split('/assets/') parts = original_path.split("/assets/")
if len(parts) == 2: 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 element.image_path = new_path
normalized_count += 1 normalized_count += 1
continue continue
@ -222,9 +219,9 @@ class AsyncProjectLoader(QThread):
print(f"Normalized {normalized_count} asset paths") print(f"Normalized {normalized_count} asset paths")
def load_from_zip_async(zip_path: str, extract_to: Optional[str] = None, def load_from_zip_async(
progress_callback=None, complete_callback=None, zip_path: str, extract_to: Optional[str] = None, progress_callback=None, complete_callback=None, error_callback=None
error_callback=None) -> AsyncProjectLoader: ) -> AsyncProjectLoader:
""" """
Load a project from a ZIP file asynchronously. Load a project from a ZIP file asynchronously.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,4 +5,4 @@ Mixin modules for pyPhotoAlbum
from pyPhotoAlbum.mixins.base import ApplicationStateMixin from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin 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. 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): def dragEnterEvent(self, event):
"""Handle drag enter events""" """Handle drag enter events"""
@ -66,7 +66,7 @@ class AssetDropMixin:
def _handle_drop_on_element(self, image_path, target_element): def _handle_drop_on_element(self, image_path, target_element):
"""Handle dropping an image onto an existing element""" """Handle dropping an image onto an existing element"""
main_window = self.window() 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 return
try: try:
@ -89,7 +89,7 @@ class AssetDropMixin:
y=placeholder.position[1], y=placeholder.position[1],
width=placeholder.size[0], width=placeholder.size[0],
height=placeholder.size[1], height=placeholder.size[1],
z_index=placeholder.z_index z_index=placeholder.z_index,
) )
if not main_window.project.pages: if not main_window.project.pages:
@ -104,7 +104,7 @@ class AssetDropMixin:
def _handle_drop_on_empty_space(self, image_path, x, y): def _handle_drop_on_empty_space(self, image_path, x, y):
"""Handle dropping an image onto empty space""" """Handle dropping an image onto empty space"""
main_window = self.window() 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 return
target_page, page_index, page_renderer = self._get_page_at(x, y) 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) img_width, img_height = self._calculate_image_dimensions(full_asset_path)
self._add_new_image_to_page( self._add_new_image_to_page(
asset_path, target_page, page_index, page_renderer, asset_path, target_page, page_index, page_renderer, x, y, img_width, img_height, main_window
x, y, img_width, img_height, main_window
) )
except Exception as e: except Exception as e:
print(f"Error importing dropped image: {e}") print(f"Error importing dropped image: {e}")
@ -138,27 +137,18 @@ class AssetDropMixin:
# Fallback dimensions if image cannot be read # Fallback dimensions if image cannot be read
return 200, 150 return 200, 150
def _add_new_image_to_page(self, asset_path, target_page, page_index, def _add_new_image_to_page(
page_renderer, x, y, img_width, img_height, main_window): 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)""" """Add a new image element to the target page (asset already imported)"""
if page_index >= 0: if page_index >= 0:
self.current_page_index = page_index self.current_page_index = page_index
page_local_x, page_local_y = page_renderer.screen_to_page(x, y) page_local_x, page_local_y = page_renderer.screen_to_page(x, y)
new_image = ImageData( new_image = ImageData(image_path=asset_path, x=page_local_x, y=page_local_y, width=img_width, height=img_height)
image_path=asset_path,
x=page_local_x,
y=page_local_y,
width=img_width,
height=img_height
)
cmd = AddElementCommand( cmd = AddElementCommand(target_page.layout, new_image, asset_manager=main_window.project.asset_manager)
target_page.layout,
new_image,
asset_manager=main_window.project.asset_manager
)
main_window.project.history.execute(cmd) 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}") 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. Override this method if the project is accessed differently.
Default implementation uses self.project.folder_path. Default implementation uses self.project.folder_path.
""" """
if hasattr(self, 'project') and self.project: if hasattr(self, "project") and self.project:
return getattr(self.project, 'folder_path', None) return getattr(self.project, "folder_path", None)
return None return None

View File

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

View File

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

View File

@ -16,12 +16,7 @@ class DialogMixin:
making it easier to create, test, and maintain complex dialogs. making it easier to create, test, and maintain complex dialogs.
""" """
def create_dialog( def create_dialog(self, dialog_class: type, title: Optional[str] = None, **kwargs) -> Optional[Any]:
self,
dialog_class: type,
title: Optional[str] = None,
**kwargs
) -> Optional[Any]:
""" """
Create and show a dialog, handling the result. Create and show a dialog, handling the result.
@ -43,18 +38,13 @@ class DialogMixin:
# Show dialog and handle result # Show dialog and handle result
if dialog.exec() == QDialog.DialogCode.Accepted: if dialog.exec() == QDialog.DialogCode.Accepted:
# Check if dialog has a get_values method # Check if dialog has a get_values method
if hasattr(dialog, 'get_values'): if hasattr(dialog, "get_values"):
return dialog.get_values() return dialog.get_values()
return True return True
return None return None
def show_dialog( def show_dialog(self, dialog_class: type, on_accept: Optional[Callable] = None, **kwargs) -> bool:
self,
dialog_class: type,
on_accept: Optional[Callable] = None,
**kwargs
) -> bool:
""" """
Show a dialog and execute callback on acceptance. 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 self.rotation_snap_angle: int = 15 # Default snap angle in degrees
# Snap state tracking # Snap state tracking
self.snap_state = { self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None}
'is_snapped': False,
'last_position': None,
'last_size': None
}
def _resize_element(self, dx: float, dy: float): def _resize_element(self, dx: float, dy: float):
""" """
@ -49,7 +45,7 @@ class ElementManipulationMixin:
# Get the snapping system from the element's parent page # Get the snapping system from the element's parent page
main_window = self.window() 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) self._resize_element_no_snap(dx, dy)
return return
@ -62,6 +58,7 @@ class ElementManipulationMixin:
# Apply snapping to resize # Apply snapping to resize
from pyPhotoAlbum.snapping import SnapResizeParams from pyPhotoAlbum.snapping import SnapResizeParams
params = SnapResizeParams( params = SnapResizeParams(
position=self.resize_start_pos, position=self.resize_start_pos,
size=self.resize_start_size, size=self.resize_start_size,
@ -70,7 +67,7 @@ class ElementManipulationMixin:
resize_handle=self.resize_handle, resize_handle=self.resize_handle,
page_size=page_size, page_size=page_size,
dpi=dpi, dpi=dpi,
project=main_window.project project=main_window.project,
) )
new_pos, new_size = snap_sys.snap_resize(params) new_pos, new_size = snap_sys.snap_resize(params)
@ -100,16 +97,16 @@ class ElementManipulationMixin:
start_x, start_y = self.resize_start_pos start_x, start_y = self.resize_start_pos
start_w, start_h = self.resize_start_size 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.position = (start_x + dx, start_y + dy)
self.selected_element.size = (start_w - dx, start_h - 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.position = (start_x, start_y + dy)
self.selected_element.size = (start_w + dx, start_h - 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.position = (start_x + dx, start_y)
self.selected_element.size = (start_w - dx, start_h + dy) 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) self.selected_element.size = (start_w + dx, start_h + dy)
# Ensure minimum size # Ensure minimum size
@ -121,7 +118,9 @@ class ElementManipulationMixin:
w, _ = self.selected_element.size w, _ = self.selected_element.size
self.selected_element.size = (w, min_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. Transfer an element from one page to another during drag operation.

View File

@ -54,7 +54,7 @@ class ElementSelectionMixin:
Returns: Returns:
BaseLayoutElement or None: The topmost element at the position, or None 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 return None
# Check each page from top to bottom (reverse z-order) # Check each page from top to bottom (reverse z-order)
@ -96,11 +96,11 @@ class ElementSelectionMixin:
return None return None
main_window = self.window() 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 return None
# Get the PageRenderer for this element (stored when element was selected) # 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 return None
renderer = self.selected_element._page_renderer renderer = self.selected_element._page_renderer
@ -117,10 +117,10 @@ class ElementSelectionMixin:
# Check handles (no rotation transformation needed - images are already rotated) # Check handles (no rotation transformation needed - images are already rotated)
handles = { handles = {
'nw': (ex - handle_size/2, ey - handle_size/2), "nw": (ex - handle_size / 2, ey - handle_size / 2),
'ne': (ex + ew - 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), "sw": (ex - handle_size / 2, ey + eh - handle_size / 2),
'se': (ex + ew - 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(): 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: def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""Check if position changed significantly.""" """Check if position changed significantly."""
old_pos = start_state.get('position') old_pos = start_state.get("position")
if old_pos is None: if old_pos is None:
return False return False
@ -68,7 +68,7 @@ class MoveCommandBuilder(CommandBuilder):
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]: def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a MoveElementCommand.""" """Build a MoveElementCommand."""
old_pos = start_state.get('position') old_pos = start_state.get("position")
if old_pos is None: if old_pos is None:
return None return None
@ -79,6 +79,7 @@ class MoveCommandBuilder(CommandBuilder):
return None return None
from pyPhotoAlbum.commands import MoveElementCommand from pyPhotoAlbum.commands import MoveElementCommand
command = MoveElementCommand(element, old_pos, new_pos) command = MoveElementCommand(element, old_pos, new_pos)
self.log_command("Move", f"{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: def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""Check if position or size changed significantly.""" """Check if position or size changed significantly."""
old_pos = start_state.get('position') old_pos = start_state.get("position")
old_size = start_state.get('size') old_size = start_state.get("size")
if old_pos is None or old_size is None: if old_pos is None or old_size is None:
return False return False
@ -106,8 +107,8 @@ class ResizeCommandBuilder(CommandBuilder):
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]: def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a ResizeElementCommand.""" """Build a ResizeElementCommand."""
old_pos = start_state.get('position') old_pos = start_state.get("position")
old_size = start_state.get('size') old_size = start_state.get("size")
if old_pos is None or old_size is None: if old_pos is None or old_size is None:
return None return None
@ -119,6 +120,7 @@ class ResizeCommandBuilder(CommandBuilder):
return None return None
from pyPhotoAlbum.commands import ResizeElementCommand from pyPhotoAlbum.commands import ResizeElementCommand
command = ResizeElementCommand(element, old_pos, old_size, new_pos, new_size) command = ResizeElementCommand(element, old_pos, old_size, new_pos, new_size)
self.log_command("Resize", f"{old_size}{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: def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""Check if rotation changed significantly.""" """Check if rotation changed significantly."""
old_rotation = start_state.get('rotation') old_rotation = start_state.get("rotation")
if old_rotation is None: if old_rotation is None:
return False return False
@ -139,7 +141,7 @@ class RotateCommandBuilder(CommandBuilder):
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]: def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a RotateElementCommand.""" """Build a RotateElementCommand."""
old_rotation = start_state.get('rotation') old_rotation = start_state.get("rotation")
if old_rotation is None: if old_rotation is None:
return None return None
@ -150,6 +152,7 @@ class RotateCommandBuilder(CommandBuilder):
return None return None
from pyPhotoAlbum.commands import RotateElementCommand from pyPhotoAlbum.commands import RotateElementCommand
command = RotateElementCommand(element, old_rotation, new_rotation) command = RotateElementCommand(element, old_rotation, new_rotation)
self.log_command("Rotation", f"{old_rotation:.1f}° → {new_rotation:.1f}°") self.log_command("Rotation", f"{old_rotation:.1f}° → {new_rotation:.1f}°")
@ -166,7 +169,7 @@ class ImagePanCommandBuilder(CommandBuilder):
if not isinstance(element, ImageData): if not isinstance(element, ImageData):
return False return False
old_crop = start_state.get('crop_info') old_crop = start_state.get("crop_info")
if old_crop is None: if old_crop is None:
return False return False
@ -181,7 +184,7 @@ class ImagePanCommandBuilder(CommandBuilder):
if not isinstance(element, ImageData): if not isinstance(element, ImageData):
return None return None
old_crop = start_state.get('crop_info') old_crop = start_state.get("crop_info")
if old_crop is None: if old_crop is None:
return None return None
@ -193,6 +196,7 @@ class ImagePanCommandBuilder(CommandBuilder):
return None return None
from pyPhotoAlbum.commands import AdjustImageCropCommand from pyPhotoAlbum.commands import AdjustImageCropCommand
command = AdjustImageCropCommand(element, old_crop, new_crop) command = AdjustImageCropCommand(element, old_crop, new_crop)
self.log_command("Image pan", f"{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, MoveCommandBuilder,
ResizeCommandBuilder, ResizeCommandBuilder,
RotateCommandBuilder, RotateCommandBuilder,
ImagePanCommandBuilder ImagePanCommandBuilder,
) )
@ -31,10 +31,10 @@ class InteractionCommandFactory:
def _register_default_builders(self): def _register_default_builders(self):
"""Register the default command builders.""" """Register the default command builders."""
self.register_builder('move', MoveCommandBuilder()) self.register_builder("move", MoveCommandBuilder())
self.register_builder('resize', ResizeCommandBuilder()) self.register_builder("resize", ResizeCommandBuilder())
self.register_builder('rotate', RotateCommandBuilder()) self.register_builder("rotate", RotateCommandBuilder())
self.register_builder('image_pan', ImagePanCommandBuilder()) self.register_builder("image_pan", ImagePanCommandBuilder())
def register_builder(self, interaction_type: str, builder: CommandBuilder): def register_builder(self, interaction_type: str, builder: CommandBuilder):
""" """
@ -46,11 +46,9 @@ class InteractionCommandFactory:
""" """
self._builders[interaction_type] = builder self._builders[interaction_type] = builder
def create_command(self, def create_command(
interaction_type: str, self, interaction_type: str, element: BaseLayoutElement, start_state: dict, **kwargs
element: BaseLayoutElement, ) -> Optional[Any]:
start_state: dict,
**kwargs) -> Optional[Any]:
""" """
Create a command based on interaction type and state changes. Create a command based on interaction type and state changes.
@ -91,13 +89,15 @@ class InteractionState:
the code more maintainable. the code more maintainable.
""" """
def __init__(self, def __init__(
self,
element: Optional[BaseLayoutElement] = None, element: Optional[BaseLayoutElement] = None,
interaction_type: Optional[str] = None, interaction_type: Optional[str] = None,
position: Optional[tuple] = None, position: Optional[tuple] = None,
size: Optional[tuple] = None, size: Optional[tuple] = None,
rotation: Optional[float] = None, rotation: Optional[float] = None,
crop_info: Optional[tuple] = None): crop_info: Optional[tuple] = None,
):
""" """
Initialize interaction state. Initialize interaction state.
@ -125,13 +125,13 @@ class InteractionState:
""" """
state = {} state = {}
if self.position is not None: if self.position is not None:
state['position'] = self.position state["position"] = self.position
if self.size is not None: if self.size is not None:
state['size'] = self.size state["size"] = self.size
if self.rotation is not None: if self.rotation is not None:
state['rotation'] = self.rotation state["rotation"] = self.rotation
if self.crop_info is not None: if self.crop_info is not None:
state['crop_info'] = self.crop_info state["crop_info"] = self.crop_info
return state return state
def is_valid(self) -> bool: def is_valid(self) -> bool:

View File

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

View File

@ -16,6 +16,7 @@ def significant_change(threshold: float = 0.1):
Returns: Returns:
None if change is insignificant, otherwise returns the command builder result None if change is insignificant, otherwise returns the command builder result
""" """
def decorator(func): def decorator(func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
@ -23,7 +24,9 @@ def significant_change(threshold: float = 0.1):
if result is None: if result is None:
return None return None
return result return result
return wrapper return wrapper
return decorator return decorator
@ -31,9 +34,9 @@ class ChangeValidator:
"""Validates whether changes are significant enough to create commands.""" """Validates whether changes are significant enough to create commands."""
@staticmethod @staticmethod
def position_changed(old_pos: Optional[Tuple[float, float]], def position_changed(
new_pos: Optional[Tuple[float, float]], old_pos: Optional[Tuple[float, float]], new_pos: Optional[Tuple[float, float]], threshold: float = 0.1
threshold: float = 0.1) -> bool: ) -> bool:
"""Check if position changed significantly.""" """Check if position changed significantly."""
if old_pos is None or new_pos is None: if old_pos is None or new_pos is None:
return False return False
@ -43,9 +46,9 @@ class ChangeValidator:
return dx > threshold or dy > threshold return dx > threshold or dy > threshold
@staticmethod @staticmethod
def size_changed(old_size: Optional[Tuple[float, float]], def size_changed(
new_size: Optional[Tuple[float, float]], old_size: Optional[Tuple[float, float]], new_size: Optional[Tuple[float, float]], threshold: float = 0.1
threshold: float = 0.1) -> bool: ) -> bool:
"""Check if size changed significantly.""" """Check if size changed significantly."""
if old_size is None or new_size is None: if old_size is None or new_size is None:
return False return False
@ -55,9 +58,7 @@ class ChangeValidator:
return dw > threshold or dh > threshold return dw > threshold or dh > threshold
@staticmethod @staticmethod
def rotation_changed(old_rotation: Optional[float], def rotation_changed(old_rotation: Optional[float], new_rotation: Optional[float], threshold: float = 0.1) -> bool:
new_rotation: Optional[float],
threshold: float = 0.1) -> bool:
"""Check if rotation changed significantly.""" """Check if rotation changed significantly."""
if old_rotation is None or new_rotation is None: if old_rotation is None or new_rotation is None:
return False return False
@ -65,9 +66,11 @@ class ChangeValidator:
return abs(new_rotation - old_rotation) > threshold return abs(new_rotation - old_rotation) > threshold
@staticmethod @staticmethod
def crop_changed(old_crop: Optional[Tuple[float, float, float, float]], def crop_changed(
old_crop: Optional[Tuple[float, float, float, float]],
new_crop: Optional[Tuple[float, float, float, float]], new_crop: Optional[Tuple[float, float, float, float]],
threshold: float = 0.001) -> bool: threshold: float = 0.001,
) -> bool:
"""Check if crop info changed significantly.""" """Check if crop info changed significantly."""
if old_crop is None or new_crop is None: if old_crop is None or new_crop is None:
return False return False
@ -85,8 +88,7 @@ class InteractionChangeDetector:
self.threshold = threshold self.threshold = threshold
self.validator = ChangeValidator() self.validator = ChangeValidator()
def detect_position_change(self, old_pos: Tuple[float, float], def detect_position_change(self, old_pos: Tuple[float, float], new_pos: Tuple[float, float]) -> Optional[dict]:
new_pos: Tuple[float, float]) -> Optional[dict]:
""" """
Detect position change and return change info. Detect position change and return change info.
@ -97,14 +99,13 @@ class InteractionChangeDetector:
return None return None
return { return {
'old_position': old_pos, "old_position": old_pos,
'new_position': new_pos, "new_position": new_pos,
'delta_x': new_pos[0] - old_pos[0], "delta_x": new_pos[0] - old_pos[0],
'delta_y': new_pos[1] - old_pos[1] "delta_y": new_pos[1] - old_pos[1],
} }
def detect_size_change(self, old_size: Tuple[float, float], def detect_size_change(self, old_size: Tuple[float, float], new_size: Tuple[float, float]) -> Optional[dict]:
new_size: Tuple[float, float]) -> Optional[dict]:
""" """
Detect size change and return change info. Detect size change and return change info.
@ -115,14 +116,13 @@ class InteractionChangeDetector:
return None return None
return { return {
'old_size': old_size, "old_size": old_size,
'new_size': new_size, "new_size": new_size,
'delta_width': new_size[0] - old_size[0], "delta_width": new_size[0] - old_size[0],
'delta_height': new_size[1] - old_size[1] "delta_height": new_size[1] - old_size[1],
} }
def detect_rotation_change(self, old_rotation: float, def detect_rotation_change(self, old_rotation: float, new_rotation: float) -> Optional[dict]:
new_rotation: float) -> Optional[dict]:
""" """
Detect rotation change and return change info. 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): if not self.validator.rotation_changed(old_rotation, new_rotation, self.threshold):
return None return None
return { return {"old_rotation": old_rotation, "new_rotation": new_rotation, "delta_angle": new_rotation - old_rotation}
'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], def detect_crop_change(
new_crop: Tuple[float, float, float, float]) -> Optional[dict]: self, old_crop: Tuple[float, float, float, float], new_crop: Tuple[float, float, float, float]
) -> Optional[dict]:
""" """
Detect crop change and return change info. 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): if not self.validator.crop_changed(old_crop, new_crop, threshold=0.001):
return None return None
return { return {"old_crop": old_crop, "new_crop": new_crop, "delta": tuple(new_crop[i] - old_crop[i] for i in range(4))}
'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): def _navigate_to_next_page(self):
"""Navigate to the next page using Page Down key""" """Navigate to the next page using Page Down key"""
main_window = self.window() 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 return
current_index = main_window._get_most_visible_page_index() current_index = main_window._get_most_visible_page_index()
@ -24,14 +24,14 @@ class KeyboardNavigationMixin:
next_page = main_window.project.pages[current_index + 1] next_page = main_window.project.pages[current_index + 1]
self._scroll_to_page(next_page, 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) page_name = main_window.project.get_page_display_name(next_page)
main_window.show_status(f"Navigated to {page_name}", 2000) main_window.show_status(f"Navigated to {page_name}", 2000)
def _navigate_to_previous_page(self): def _navigate_to_previous_page(self):
"""Navigate to the previous page using Page Up key""" """Navigate to the previous page using Page Up key"""
main_window = self.window() 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 return
current_index = main_window._get_most_visible_page_index() current_index = main_window._get_most_visible_page_index()
@ -39,7 +39,7 @@ class KeyboardNavigationMixin:
prev_page = main_window.project.pages[current_index - 1] prev_page = main_window.project.pages[current_index - 1]
self._scroll_to_page(prev_page, 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) page_name = main_window.project.get_page_display_name(prev_page)
main_window.show_status(f"Navigated to {page_name}", 2000) 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 page_index: The index of the page in the project
""" """
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project'): if not hasattr(main_window, "project"):
return return
dpi = main_window.project.working_dpi dpi = main_window.project.working_dpi
@ -79,14 +79,14 @@ class KeyboardNavigationMixin:
self.pan_offset[1] = target_pan_y self.pan_offset[1] = target_pan_y
# Clamp pan offset to content bounds # Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'): if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset() self.clamp_pan_offset()
self.update() self.update()
# Update scrollbars if available # Update scrollbars if available
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'update_scrollbars'): if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars() main_window.update_scrollbars()
def _move_viewport_with_arrow_keys(self, key): def _move_viewport_with_arrow_keys(self, key):
@ -109,14 +109,14 @@ class KeyboardNavigationMixin:
self.pan_offset[0] -= move_amount self.pan_offset[0] -= move_amount
# Clamp pan offset to content bounds # Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'): if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset() self.clamp_pan_offset()
self.update() self.update()
# Update scrollbars if available # Update scrollbars if available
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'update_scrollbars'): if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars() main_window.update_scrollbars()
def _move_selected_elements_with_arrow_keys(self, key): def _move_selected_elements_with_arrow_keys(self, key):
@ -127,7 +127,7 @@ class KeyboardNavigationMixin:
key: The Qt key code (Up, Down, Left, Right) key: The Qt key code (Up, Down, Left, Right)
""" """
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project'): if not hasattr(main_window, "project"):
return return
# Movement amount in mm # Movement amount in mm
@ -151,7 +151,7 @@ class KeyboardNavigationMixin:
new_y = current_y + dy new_y = current_y + dy
# Apply snapping if element has a parent page # 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 page = element._parent_page
snap_sys = page.layout.snapping_system snap_sys = page.layout.snapping_system
page_size = page.layout.size page_size = page.layout.size
@ -162,7 +162,7 @@ class KeyboardNavigationMixin:
size=element.size, size=element.size,
page_size=page_size, page_size=page_size,
dpi=dpi, dpi=dpi,
project=main_window.project project=main_window.project,
) )
element.position = snapped_pos element.position = snapped_pos
else: else:
@ -170,7 +170,7 @@ class KeyboardNavigationMixin:
self.update() self.update()
if hasattr(main_window, 'show_status'): if hasattr(main_window, "show_status"):
count = len(self.selected_elements) count = len(self.selected_elements)
elem_text = "element" if count == 1 else "elements" elem_text = "element" if count == 1 else "elements"
main_window.show_status(f"Moved {count} {elem_text}", 1000) 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) element = self._get_element_at(x, y)
if element: 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 # 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: if ctrl_pressed and isinstance(element, ImageData) and not self.rotation_mode:
# Enter image pan mode - pan image within frame # Enter image pan mode - pan image within frame
@ -129,7 +131,7 @@ class MouseInteractionMixin:
self.pan_offset[1] += dy self.pan_offset[1] += dy
# Clamp pan offset to content bounds # Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'): if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset() self.clamp_pan_offset()
self.drag_start_pos = (x, y) self.drag_start_pos = (x, y)
@ -137,7 +139,7 @@ class MouseInteractionMixin:
# Update scrollbars if available # Update scrollbars if available
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'update_scrollbars'): if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars() main_window.update_scrollbars()
return return
@ -152,7 +154,7 @@ class MouseInteractionMixin:
elif self.rotation_mode: elif self.rotation_mode:
# Rotation mode # Rotation mode
if not hasattr(self.selected_element, '_page_renderer'): if not hasattr(self.selected_element, "_page_renderer"):
return return
renderer = self.selected_element._page_renderer renderer = self.selected_element._page_renderer
@ -173,7 +175,7 @@ class MouseInteractionMixin:
self.selected_element.rotation = angle self.selected_element.rotation = angle
main_window = self.window() 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) main_window.show_status(f"Rotation: {angle:.1f}°", 100)
elif self.resize_handle: elif self.resize_handle:
@ -189,11 +191,13 @@ class MouseInteractionMixin:
# Move mode # Move mode
current_page, current_page_index, current_renderer = self._get_page_at(x, y) 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 source_page = self.selected_element._parent_page
if current_page is not source_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: else:
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
total_dy = (y - self.drag_start_pos[1]) / 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, size=self.selected_element.size,
page_size=page_size, page_size=page_size,
dpi=dpi, dpi=dpi,
project=main_window.project project=main_window.project,
) )
self.selected_element.position = snapped_pos self.selected_element.position = snapped_pos
@ -238,11 +242,7 @@ class MouseInteractionMixin:
self.rotation_start_angle = None self.rotation_start_angle = None
self.image_pan_mode = False self.image_pan_mode = False
self.image_pan_start_crop = None self.image_pan_start_crop = None
self.snap_state = { self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None}
'is_snapped': False,
'last_position': None,
'last_size': None
}
self.setCursor(Qt.CursorShape.ArrowCursor) self.setCursor(Qt.CursorShape.ArrowCursor)
elif event.button() == Qt.MouseButton.MiddleButton: elif event.button() == Qt.MouseButton.MiddleButton:
@ -257,6 +257,7 @@ class MouseInteractionMixin:
element = self._get_element_at(x, y) element = self._get_element_at(x, y)
from pyPhotoAlbum.models import TextBoxData from pyPhotoAlbum.models import TextBoxData
if isinstance(element, TextBoxData): if isinstance(element, TextBoxData):
self._edit_text_element(element) self._edit_text_element(element)
return return
@ -293,23 +294,20 @@ class MouseInteractionMixin:
if self.is_dragging and self.drag_start_pos: if self.is_dragging and self.drag_start_pos:
pan_delta_x = self.pan_offset[0] - old_pan_x pan_delta_x = self.pan_offset[0] - old_pan_x
pan_delta_y = self.pan_offset[1] - old_pan_y pan_delta_y = self.pan_offset[1] - old_pan_y
self.drag_start_pos = ( self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
self.drag_start_pos[0] + pan_delta_x,
self.drag_start_pos[1] + pan_delta_y
)
# Clamp pan offset to content bounds # Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'): if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset() self.clamp_pan_offset()
self.update() self.update()
main_window = self.window() 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) main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)
# Update scrollbars if available # Update scrollbars if available
if hasattr(main_window, 'update_scrollbars'): if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars() main_window.update_scrollbars()
else: else:
# Regular wheel: Vertical scroll # Regular wheel: Vertical scroll
@ -318,22 +316,19 @@ class MouseInteractionMixin:
self.pan_offset[1] += scroll_amount self.pan_offset[1] += scroll_amount
# Clamp pan offset to content bounds # Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'): if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset() self.clamp_pan_offset()
# If dragging, adjust drag_start_pos to account for pan_offset change # If dragging, adjust drag_start_pos to account for pan_offset change
if self.is_dragging and self.drag_start_pos: if self.is_dragging and self.drag_start_pos:
pan_delta_y = self.pan_offset[1] - old_pan_y pan_delta_y = self.pan_offset[1] - old_pan_y
self.drag_start_pos = ( self.drag_start_pos = (self.drag_start_pos[0], self.drag_start_pos[1] + pan_delta_y)
self.drag_start_pos[0],
self.drag_start_pos[1] + pan_delta_y
)
self.update() self.update()
# Update scrollbars if available # Update scrollbars if available
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'update_scrollbars'): if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars() main_window.update_scrollbars()
def _edit_text_element(self, text_element): def _edit_text_element(self, text_element):
@ -344,9 +339,9 @@ class MouseInteractionMixin:
if dialog.exec() == TextEditDialog.DialogCode.Accepted: if dialog.exec() == TextEditDialog.DialogCode.Accepted:
values = dialog.get_values() values = dialog.get_values()
text_element.text_content = values['text_content'] text_element.text_content = values["text_content"]
text_element.font_settings = values['font_settings'] text_element.font_settings = values["font_settings"]
text_element.alignment = values['alignment'] text_element.alignment = values["alignment"]
self.update() self.update()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@ class MergeOperationsMixin:
label="Merge Projects", label="Merge Projects",
tooltip="Merge another project file with the current project", tooltip="Merge another project file with the current project",
tab="File", tab="File",
group="Import/Export" group="Import/Export",
) )
def merge_projects(self): def merge_projects(self):
""" """
@ -35,22 +35,19 @@ class MergeOperationsMixin:
self, self,
"Unsaved Changes", "Unsaved Changes",
"You have unsaved changes in the current project. Save before merging?", "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: if reply == QMessageBox.StandardButton.Cancel:
return return
elif reply == QMessageBox.StandardButton.Yes: elif reply == QMessageBox.StandardButton.Yes:
# Save current project first # Save current project first
if hasattr(self, 'save_project'): if hasattr(self, "save_project"):
self.save_project() self.save_project()
# Select file to merge # Select file to merge
file_path, _ = QFileDialog.getOpenFileName( file_path, _ = QFileDialog.getOpenFileName(
self, self, "Select Project to Merge", "", "Photo Album Projects (*.ppz);;All Files (*)"
"Select Project to Merge",
"",
"Photo Album Projects (*.ppz);;All Files (*)"
) )
if not file_path: if not file_path:
@ -58,7 +55,7 @@ class MergeOperationsMixin:
try: try:
# Disable autosave during merge # Disable autosave during merge
if hasattr(self, '_autosave_timer'): if hasattr(self, "_autosave_timer"):
self._autosave_timer.stop() self._autosave_timer.stop()
# Load the other project # Load the other project
@ -82,14 +79,10 @@ class MergeOperationsMixin:
self._perform_concatenation(our_data, their_data) self._perform_concatenation(our_data, their_data)
except Exception as e: except Exception as e:
QMessageBox.critical( QMessageBox.critical(self, "Merge Error", f"Failed to merge projects:\n{str(e)}")
self,
"Merge Error",
f"Failed to merge projects:\n{str(e)}"
)
finally: finally:
# Re-enable autosave # Re-enable autosave
if hasattr(self, '_autosave_timer'): if hasattr(self, "_autosave_timer"):
self._autosave_timer.start() self._autosave_timer.start()
def _perform_merge_with_conflicts(self, our_data, their_data): def _perform_merge_with_conflicts(self, our_data, their_data):
@ -104,7 +97,7 @@ class MergeOperationsMixin:
self, self,
"No Conflicts", "No Conflicts",
"No conflicts detected. Merge projects automatically?", "No conflicts detected. Merge projects automatically?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if reply != QMessageBox.StandardButton.Yes: if reply != QMessageBox.StandardButton.Yes:
@ -117,11 +110,7 @@ class MergeOperationsMixin:
dialog = MergeDialog(our_data, their_data, self) dialog = MergeDialog(our_data, their_data, self)
if dialog.exec() != QMessageBox.DialogCode.Accepted: if dialog.exec() != QMessageBox.DialogCode.Accepted:
QMessageBox.information( QMessageBox.information(self, "Merge Cancelled", "Merge operation cancelled.")
self,
"Merge Cancelled",
"Merge operation cancelled."
)
return return
# Get merged data from dialog # Get merged data from dialog
@ -135,7 +124,7 @@ class MergeOperationsMixin:
"Merge Complete", "Merge Complete",
f"Projects merged successfully.\n" f"Projects merged successfully.\n"
f"Total pages: {len(merged_data.get('pages', []))}\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): def _perform_concatenation(self, our_data, their_data):
@ -147,7 +136,7 @@ class MergeOperationsMixin:
f"{our_data.get('name', 'Untitled')}\n" f"{our_data.get('name', 'Untitled')}\n"
f"{their_data.get('name', 'Untitled')}\n\n" f"{their_data.get('name', 'Untitled')}\n\n"
f"Concatenate them (combine all pages)?", f"Concatenate them (combine all pages)?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if reply != QMessageBox.StandardButton.Yes: if reply != QMessageBox.StandardButton.Yes:
@ -162,8 +151,7 @@ class MergeOperationsMixin:
QMessageBox.information( QMessageBox.information(
self, self,
"Concatenation Complete", "Concatenation Complete",
f"Projects concatenated successfully.\n" f"Projects concatenated successfully.\n" f"Total pages: {len(merged_data.get('pages', []))}",
f"Total pages: {len(merged_data.get('pages', []))}"
) )
def _apply_merged_data(self, merged_data): def _apply_merged_data(self, merged_data):
@ -182,9 +170,9 @@ class MergeOperationsMixin:
new_project.mark_dirty() new_project.mark_dirty()
# Update UI # Update UI
if hasattr(self, 'gl_widget'): if hasattr(self, "gl_widget"):
self.gl_widget.set_project(new_project) self.gl_widget.set_project(new_project)
self.gl_widget.update() self.gl_widget.update()
if hasattr(self, 'status_bar'): if hasattr(self, "status_bar"):
self.status_bar.showMessage("Merge completed successfully", 3000) 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 # Note: Previous/Next page navigation removed - now using scrollable multi-page view
# User can scroll through all pages vertically # User can scroll through all pages vertically
@ribbon_action( @ribbon_action(label="Add Page", tooltip="Add a new page to the project", tab="Layout", group="Page")
label="Add Page",
tooltip="Add a new page to the project",
tab="Layout",
group="Page"
)
def add_page(self): def add_page(self):
"""Add a new page to the project after the current page""" """Add a new page to the project after the current page"""
# Get the most visible page in viewport to determine insertion point # 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) 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") print(f"Added {new_page_name} at position {insert_index + 1} with size {width_mm}×{height_mm} mm")
@ribbon_action( @ribbon_action(label="Page Setup", tooltip="Configure page size and settings", tab="Layout", group="Page")
label="Page Setup",
tooltip="Configure page size and settings",
tab="Layout",
group="Page"
)
@dialog_action(dialog_class=PageSetupDialog, requires_pages=True) @dialog_action(dialog_class=PageSetupDialog, requires_pages=True)
def page_setup(self, values): def page_setup(self, values):
""" """
@ -90,17 +80,17 @@ class PageOperationsMixin:
Args: Args:
values: Dictionary of values from the dialog values: Dictionary of values from the dialog
""" """
selected_page = values['selected_page'] selected_page = values["selected_page"]
selected_index = values['selected_index'] selected_index = values["selected_index"]
# Update project cover settings # Update project cover settings
self.project.paper_thickness_mm = values['paper_thickness_mm'] self.project.paper_thickness_mm = values["paper_thickness_mm"]
self.project.cover_bleed_mm = values['cover_bleed_mm'] self.project.cover_bleed_mm = values["cover_bleed_mm"]
# Handle cover designation (only for first page) # Handle cover designation (only for first page)
if selected_index == 0: if selected_index == 0:
was_cover = selected_page.is_cover was_cover = selected_page.is_cover
is_cover = values['is_cover'] is_cover = values["is_cover"]
if was_cover != is_cover: if was_cover != is_cover:
selected_page.is_cover = is_cover selected_page.is_cover = is_cover
@ -116,8 +106,8 @@ class PageOperationsMixin:
print(f"Cover removed from page 1") print(f"Cover removed from page 1")
# Get new values # Get new values
width_mm = values['width_mm'] width_mm = values["width_mm"]
height_mm = values['height_mm'] height_mm = values["height_mm"]
# Don't allow manual size changes for covers # Don't allow manual size changes for covers
if not selected_page.is_cover: if not selected_page.is_cover:
@ -126,11 +116,11 @@ class PageOperationsMixin:
if selected_page.is_double_spread: if selected_page.is_double_spread:
old_base_width = ( old_base_width = (
selected_page.layout.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 else selected_page.layout.size[0] / 2
) )
old_height = selected_page.layout.size[1] 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: if size_changed:
# Update double spread # Update double spread
@ -143,7 +133,7 @@ class PageOperationsMixin:
) )
else: else:
old_size = selected_page.layout.size 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: if size_changed:
# Update single page # Update single page
@ -151,16 +141,15 @@ class PageOperationsMixin:
selected_page.layout.base_width = width_mm selected_page.layout.base_width = width_mm
selected_page.manually_sized = True selected_page.manually_sized = True
print( print(
f"{self.project.get_page_display_name(selected_page)} " f"{self.project.get_page_display_name(selected_page)} " f"updated to {width_mm}×{height_mm} mm"
f"updated to {width_mm}×{height_mm} mm"
) )
# Update DPI settings # Update DPI settings
self.project.working_dpi = values['working_dpi'] self.project.working_dpi = values["working_dpi"]
self.project.export_dpi = values['export_dpi'] self.project.export_dpi = values["export_dpi"]
# Set as default if checkbox is checked # 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) self.project.page_size_mm = (width_mm, height_mm)
print(f"Project default page size set to {width_mm}×{height_mm} 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" status_msg = f"{page_name} updated"
else: else:
status_msg = f"{page_name} size: {width_mm}×{height_mm} mm" 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)" status_msg += " (set as default)"
self.show_status(status_msg, 2000) self.show_status(status_msg, 2000)
@ribbon_action( @ribbon_action(
label="Toggle Spread", label="Toggle Spread", tooltip="Toggle double page spread for current page", tab="Layout", group="Page"
tooltip="Toggle double page spread for current page",
tab="Layout",
group="Page"
) )
def toggle_double_spread(self): def toggle_double_spread(self):
"""Toggle double spread for the current page""" """Toggle double spread for the current page"""
@ -208,7 +194,7 @@ class PageOperationsMixin:
current_height = current_page.layout.size[1] current_height = current_page.layout.size[1]
# Get base width (might already be doubled) # 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 base_width = current_page.layout.base_width
else: else:
# Assume current width is single if not marked as facing # 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) 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") print(f"{page_name}: Double spread {status}, width = {new_width}mm")
@ribbon_action( @ribbon_action(label="Remove Page", tooltip="Remove the currently selected page", tab="Layout", group="Page")
label="Remove Page",
tooltip="Remove the currently selected page",
tab="Layout",
group="Page"
)
def remove_page(self): def remove_page(self):
"""Remove the currently selected page""" """Remove the currently selected page"""
if len(self.project.pages) <= 1: if len(self.project.pages) <= 1:

View File

@ -20,7 +20,7 @@ class SizeOperationsMixin:
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def make_same_size(self): def make_same_size(self):
"""Make all selected elements the same size""" """Make all selected elements the same size"""
@ -41,7 +41,7 @@ class SizeOperationsMixin:
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def make_same_width(self): def make_same_width(self):
"""Make all selected elements the same width""" """Make all selected elements the same width"""
@ -62,7 +62,7 @@ class SizeOperationsMixin:
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def make_same_height(self): def make_same_height(self):
"""Make all selected elements the same height""" """Make all selected elements the same height"""
@ -83,7 +83,7 @@ class SizeOperationsMixin:
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=1 min_selection=1,
) )
def fit_to_width(self): def fit_to_width(self):
"""Fit selected element to page width""" """Fit selected element to page width"""
@ -114,7 +114,7 @@ class SizeOperationsMixin:
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=1 min_selection=1,
) )
def fit_to_height(self): def fit_to_height(self):
"""Fit selected element to page height""" """Fit selected element to page height"""
@ -145,7 +145,7 @@ class SizeOperationsMixin:
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=1 min_selection=1,
) )
def fit_to_page(self): def fit_to_page(self):
"""Fit selected element to page dimensions""" """Fit selected element to page dimensions"""
@ -176,7 +176,7 @@ class SizeOperationsMixin:
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=1 min_selection=1,
) )
def expand_image(self): def expand_image(self):
"""Expand selected image to fill available space""" """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] 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) # 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 # Expand to bounds
page_width, page_height = page.layout.size page_width, page_height = page.layout.size
change = AlignmentManager.expand_to_bounds( change = AlignmentManager.expand_to_bounds(element, (page_width, page_height), other_elements, min_gap)
element,
(page_width, page_height),
other_elements,
min_gap
)
if change: if change:
cmd = ResizeElementsCommand([change]) cmd = ResizeElementsCommand([change])

View File

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

View File

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

View File

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

View File

@ -33,11 +33,11 @@ class PageNavigationMixin:
Returns: Returns:
Tuple of (page, page_index, renderer) or (None, -1, None) if no page at coordinates 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 return None, -1, None
main_window = self.window() 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 return None, -1, None
# Check each page to find which one contains the coordinates # 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) List of tuples (page_type, page_or_ghost_data, y_offset)
""" """
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project'): if not hasattr(main_window, "project"):
return [] return []
dpi = main_window.project.working_dpi dpi = main_window.project.working_dpi
@ -77,7 +77,7 @@ class PageNavigationMixin:
# First, render cover if it exists # First, render cover if it exists
for page in main_window.project.pages: for page in main_window.project.pages:
if page.is_cover: if page.is_cover:
result.append(('page', page, current_y)) result.append(("page", page, current_y))
# Calculate cover height in pixels # Calculate cover height in pixels
page_height_mm = page.layout.size[1] page_height_mm = page.layout.size[1]
@ -91,7 +91,7 @@ class PageNavigationMixin:
layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts() layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts()
for page_type, page_obj, logical_pos in 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) # Regular page (single or double spread)
result.append((page_type, page_obj, current_y)) result.append((page_type, page_obj, current_y))
@ -103,7 +103,7 @@ class PageNavigationMixin:
# Move to next position (add height + spacing) # Move to next position (add height + spacing)
current_y += page_height_px + spacing_px current_y += page_height_px + spacing_px
elif page_type == 'ghost': elif page_type == "ghost":
# Ghost page - use default page size # Ghost page - use default page size
page_size_mm = main_window.project.page_size_mm page_size_mm = main_window.project.page_size_mm
from pyPhotoAlbum.models import GhostPageData from pyPhotoAlbum.models import GhostPageData
@ -131,11 +131,11 @@ class PageNavigationMixin:
Returns: Returns:
bool: True if a ghost page was clicked and a new page was created 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 return False
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project'): if not hasattr(main_window, "project"):
return False return False
# Get page positions which includes ghosts # Get page positions which includes ghosts
@ -144,7 +144,7 @@ class PageNavigationMixin:
# Check each position for ghost pages # Check each position for ghost pages
for idx, (page_type, page_or_ghost, y_offset) in enumerate(page_positions): for idx, (page_type, page_or_ghost, y_offset) in enumerate(page_positions):
# Skip non-ghost pages # Skip non-ghost pages
if page_type != 'ghost': if page_type != "ghost":
continue continue
ghost = page_or_ghost ghost = page_or_ghost
@ -156,20 +156,21 @@ class PageNavigationMixin:
screen_y = (y_offset * self.zoom_level) + self.pan_offset[1] screen_y = (y_offset * self.zoom_level) + self.pan_offset[1]
from pyPhotoAlbum.page_renderer import PageRenderer from pyPhotoAlbum.page_renderer import PageRenderer
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=ghost_width_mm, page_width_mm=ghost_width_mm,
page_height_mm=ghost_height_mm, page_height_mm=ghost_height_mm,
screen_x=screen_x, screen_x=screen_x,
screen_y=screen_y, screen_y=screen_y,
dpi=dpi, dpi=dpi,
zoom=self.zoom_level zoom=self.zoom_level,
) )
# Check if click is anywhere on the ghost page (entire page is clickable) # Check if click is anywhere on the ghost page (entire page is clickable)
if renderer.is_point_in_page(x, y): if renderer.is_point_in_page(x, y):
# User clicked the ghost page! # User clicked the ghost page!
# Calculate the insertion index (count real pages before this ghost in page_positions) # 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") 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_number = insert_index + 1
new_page = Page( new_page = Page(
layout=PageLayout( layout=PageLayout(
width=main_window.project.page_size_mm[0], width=main_window.project.page_size_mm[0], height=main_window.project.page_size_mm[1]
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 # Insert the page at the correct position
@ -209,10 +209,10 @@ class PageNavigationMixin:
y: Screen Y coordinate y: Screen Y coordinate
""" """
main_window = self.window() 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 return
if not hasattr(self, '_page_renderers') or not self._page_renderers: if not hasattr(self, "_page_renderers") or not self._page_renderers:
return return
# Get total page count (accounting for double spreads = 2 pages each) # Get total page count (accounting for double spreads = 2 pages each)
@ -228,7 +228,7 @@ class PageNavigationMixin:
if page.is_double_spread: if page.is_double_spread:
side = renderer.get_sub_page_at(x, is_facing_page=True) side = renderer.get_sub_page_at(x, is_facing_page=True)
page_nums = page.get_page_numbers() page_nums = page.get_page_numbers()
if side == 'left': if side == "left":
current_page_info = f"Page {page_nums[0]}" current_page_info = f"Page {page_nums[0]}"
else: else:
current_page_info = f"Page {page_nums[1]}" current_page_info = f"Page {page_nums[1]}"
@ -237,8 +237,10 @@ class PageNavigationMixin:
break break
# Update status bar # Update status bar
if hasattr(main_window, 'status_bar'): if hasattr(main_window, "status_bar"):
if current_page_info: 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: else:
main_window.status_bar.showMessage(f"Total pages: {total_pages} | Zoom: {int(self.zoom_level * 100)}%") 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() glLoadIdentity()
main_window = self.window() 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 return
# Set initial zoom and center the page if not done yet # 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 # Update scrollbars now that we have content bounds
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'update_scrollbars'): if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars() main_window.update_scrollbars()
dpi = main_window.project.working_dpi dpi = main_window.project.working_dpi
@ -55,7 +55,7 @@ class RenderingMixin:
for page_info in page_positions: for page_info in page_positions:
page_type, page_or_ghost, y_offset = page_info page_type, page_or_ghost, y_offset = page_info
if page_type == 'page': if page_type == "page":
page = page_or_ghost page = page_or_ghost
page_width_mm, page_height_mm = page.layout.size page_width_mm, page_height_mm = page.layout.size
@ -68,7 +68,7 @@ class RenderingMixin:
screen_x=screen_x, screen_x=screen_x,
screen_y=screen_y, screen_y=screen_y,
dpi=dpi, dpi=dpi,
zoom=self.zoom_level zoom=self.zoom_level,
) )
self._page_renderers.append((renderer, page)) self._page_renderers.append((renderer, page))
@ -79,7 +79,7 @@ class RenderingMixin:
page.layout.render(dpi=dpi, project=main_window.project) page.layout.render(dpi=dpi, project=main_window.project)
renderer.end_render() renderer.end_render()
elif page_type == 'ghost': elif page_type == "ghost":
ghost = page_or_ghost ghost = page_or_ghost
ghost_width_mm, ghost_height_mm = ghost.page_size ghost_width_mm, ghost_height_mm = ghost.page_size
@ -92,14 +92,14 @@ class RenderingMixin:
screen_x=screen_x, screen_x=screen_x,
screen_y=screen_y, screen_y=screen_y,
dpi=dpi, dpi=dpi,
zoom=self.zoom_level zoom=self.zoom_level,
) )
self._render_ghost_page(ghost, renderer) self._render_ghost_page(ghost, renderer)
# Update PageRenderer references for selected elements # Update PageRenderer references for selected elements
for element in self.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: for renderer, page in self._page_renderers:
if page is element._parent_page: if page is element._parent_page:
element._page_renderer = renderer element._page_renderer = renderer
@ -118,10 +118,10 @@ class RenderingMixin:
return return
main_window = self.window() 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 return
if not hasattr(element, '_page_renderer'): if not hasattr(element, "_page_renderer"):
return return
renderer = element._page_renderer renderer = element._page_renderer
@ -214,7 +214,7 @@ class RenderingMixin:
def _render_text_overlays(self): def _render_text_overlays(self):
"""Render text content for TextBoxData elements using QPainter overlay""" """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 return
painter = QPainter(self) painter = QPainter(self)
@ -236,12 +236,12 @@ class RenderingMixin:
screen_w = w * renderer.zoom screen_w = w * renderer.zoom
screen_h = h * renderer.zoom screen_h = h * renderer.zoom
font_family = element.font_settings.get('family', 'Arial') font_family = element.font_settings.get("family", "Arial")
font_size = int(element.font_settings.get('size', 12) * renderer.zoom) font_size = int(element.font_settings.get("size", 12) * renderer.zoom)
font = QFont(font_family, font_size) font = QFont(font_family, font_size)
painter.setFont(font) 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): if all(isinstance(c, int) and c > 1 for c in font_color):
color = QColor(*font_color) color = QColor(*font_color)
else: else:
@ -260,9 +260,9 @@ class RenderingMixin:
rect = QRectF(screen_x, screen_y, screen_w, screen_h) rect = QRectF(screen_x, screen_y, screen_w, screen_h)
alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
if element.alignment == 'center': if element.alignment == "center":
alignment = Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop alignment = Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop
elif element.alignment == 'right': elif element.alignment == "right":
alignment = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop alignment = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop
text_flags = Qt.TextFlag.TextWordWrap text_flags = Qt.TextFlag.TextWordWrap

View File

@ -61,7 +61,7 @@ class ViewportMixin:
# Update scrollbars when viewport size changes # Update scrollbars when viewport size changes
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'update_scrollbars'): if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars() main_window.update_scrollbars()
def _calculate_fit_to_screen_zoom(self): def _calculate_fit_to_screen_zoom(self):
@ -72,7 +72,7 @@ class ViewportMixin:
float: Zoom level (1.0 = 100%, 0.5 = 50%, etc.) float: Zoom level (1.0 = 100%, 0.5 = 50%, etc.)
""" """
main_window = self.window() 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 return 1.0
window_width = self.width() window_width = self.width()
@ -106,7 +106,7 @@ class ViewportMixin:
list: [x_offset, y_offset] to center the page list: [x_offset, y_offset] to center the page
""" """
main_window = self.window() 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] return [0, 0]
window_width = self.width() window_width = self.width()
@ -141,8 +141,8 @@ class ViewportMixin:
dict: {'min_x', 'max_x', 'min_y', 'max_y', 'width', 'height'} in pixels dict: {'min_x', 'max_x', 'min_y', 'max_y', 'width', 'height'} in pixels
""" """
main_window = self.window() 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 {'min_x': 0, 'max_x': 800, 'min_y': 0, 'max_y': 600, 'width': 800, 'height': 600} return {"min_x": 0, "max_x": 800, "min_y": 0, "max_y": 600, "width": 800, "height": 600}
dpi = main_window.project.working_dpi dpi = main_window.project.working_dpi
PAGE_MARGIN = 50 PAGE_MARGIN = 50
@ -167,12 +167,12 @@ class ViewportMixin:
total_height += PAGE_MARGIN total_height += PAGE_MARGIN
return { return {
'min_x': 0, "min_x": 0,
'max_x': total_width, "max_x": total_width,
'min_y': 0, "min_y": 0,
'max_y': total_height, "max_y": total_height,
'width': total_width, "width": total_width,
'height': total_height "height": total_height,
} }
def clamp_pan_offset(self): def clamp_pan_offset(self):
@ -188,7 +188,7 @@ class ViewportMixin:
when zooming on pages of different widths. when zooming on pages of different widths.
""" """
main_window = self.window() 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 return
viewport_width = self.width() viewport_width = self.width()
@ -200,7 +200,7 @@ class ViewportMixin:
# Vertical clamping # Vertical clamping
bounds = self.get_content_bounds() bounds = self.get_content_bounds()
content_height = bounds['height'] content_height = bounds["height"]
# Save original pan_offset[1] BEFORE clamping for page selection # Save original pan_offset[1] BEFORE clamping for page selection
# This prevents clamping from changing which page we think we're on # 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.image_utils import apply_pil_rotation, calculate_center_crop_coords
from pyPhotoAlbum.gl_imports import ( from pyPhotoAlbum.gl_imports import (
GL_AVAILABLE, glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_AVAILABLE,
GL_QUADS, GL_LINE_LOOP, glEnable, glDisable, GL_TEXTURE_2D, glBegin,
glBindTexture, glTexCoord2f, glTexParameteri, GL_TEXTURE_MIN_FILTER, glEnd,
GL_TEXTURE_MAG_FILTER, GL_LINEAR, glGenTextures, glTexImage2D, glVertex2f,
GL_RGBA, GL_UNSIGNED_BYTE, glDeleteTextures, glGetString, GL_VERSION, glColor3f,
glLineStipple, GL_LINE_STIPPLE, glPushMatrix, glPopMatrix, glColor4f,
glTranslatef, glRotatef, GL_BLEND, glBlendFunc, GL_SRC_ALPHA, 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, 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.""" """Get the current asset resolution context."""
return _primary_project_folder, _asset_search_paths return _primary_project_folder, _asset_search_paths
class BaseLayoutElement(ABC): class BaseLayoutElement(ABC):
"""Abstract base class for all layout elements""" """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.position = (x, y)
self.size = (width, height) self.size = (width, height)
self.rotation = rotation self.rotation = rotation
@ -118,11 +147,17 @@ class BaseLayoutElement(ABC):
"""Deserialize from a dictionary""" """Deserialize from a dictionary"""
pass pass
class ImageData(BaseLayoutElement): class ImageData(BaseLayoutElement):
"""Class to store image data and properties""" """Class to store image data and properties"""
def __init__(self, image_path: str = "", crop_info: Optional[Tuple] = None, def __init__(
image_dimensions: Optional[Tuple[int, int]] = None, **kwargs): self,
image_path: str = "",
crop_info: Optional[Tuple] = None,
image_dimensions: Optional[Tuple[int, int]] = None,
**kwargs,
):
super().__init__(**kwargs) super().__init__(**kwargs)
self.image_path = image_path self.image_path = image_path
self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop
@ -190,15 +225,15 @@ class ImageData(BaseLayoutElement):
# Create texture from pending image if one exists (deferred from async load) # Create texture from pending image if one exists (deferred from async load)
# Texture creation must happen during render when GL context is active # 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() self._create_texture_from_pending_image()
# Use cached texture if available # 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 texture_id = self._texture_id
# Get image dimensions (from loaded texture or metadata) # 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 img_width, img_height = self._img_width, self._img_height
elif self.image_dimensions: elif self.image_dimensions:
img_width, img_height = 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) img_width, img_height = int(w), int(h)
# Calculate texture coordinates for center crop with element's crop_info # Calculate texture coordinates for center crop with element's crop_info
tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords( tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords(img_width, img_height, w, h, self.crop_info)
img_width, img_height, w, h, self.crop_info
)
# Enable texturing and draw with crop # Enable texturing and draw with crop
glEnable(GL_TEXTURE_2D) 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 glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is
glBegin(GL_QUADS) glBegin(GL_QUADS)
glTexCoord2f(tx_min, ty_min); glVertex2f(x, y) glTexCoord2f(tx_min, ty_min)
glTexCoord2f(tx_max, ty_min); glVertex2f(x + w, y) glVertex2f(x, y)
glTexCoord2f(tx_max, ty_max); glVertex2f(x + w, y + h) glTexCoord2f(tx_max, ty_min)
glTexCoord2f(tx_min, ty_max); glVertex2f(x, y + h) glVertex2f(x + w, y)
glTexCoord2f(tx_max, ty_max)
glVertex2f(x + w, y + h)
glTexCoord2f(tx_min, ty_max)
glVertex2f(x, y + h)
glEnd() glEnd()
glDisable(GL_TEXTURE_2D) glDisable(GL_TEXTURE_2D)
@ -254,7 +291,7 @@ class ImageData(BaseLayoutElement):
"z_index": self.z_index, "z_index": self.z_index,
"image_path": self.image_path, "image_path": self.image_path,
"crop_info": self.crop_info, "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 # Include image dimensions metadata if available
if self.image_dimensions: 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}") logger.debug(f"ImageData: Async load completed for {self.image_path}, size: {pil_image.size}")
# Apply PIL-level rotation if needed # 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) 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}") 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. Create OpenGL texture from pending PIL image.
Called during render() when GL context is active. 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 return False
try: try:
@ -355,11 +392,11 @@ class ImageData(BaseLayoutElement):
pil_image = self._pending_pil_image pil_image = self._pending_pil_image
# Ensure RGBA format for GL_RGBA texture (defensive check) # Ensure RGBA format for GL_RGBA texture (defensive check)
if pil_image.mode != 'RGBA': if pil_image.mode != "RGBA":
pil_image = pil_image.convert('RGBA') pil_image = pil_image.convert("RGBA")
# Delete old texture if it exists # 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]) glDeleteTextures([self._texture_id])
# Create GPU texture from pre-processed PIL image # Create GPU texture from pre-processed PIL image
@ -369,8 +406,9 @@ class ImageData(BaseLayoutElement):
glBindTexture(GL_TEXTURE_2D, texture_id) glBindTexture(GL_TEXTURE_2D, texture_id)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_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, glTexImage2D(
0, GL_RGBA, GL_UNSIGNED_BYTE, img_data) GL_TEXTURE_2D, 0, GL_RGBA, pil_image.width, pil_image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data
)
# Cache texture # Cache texture
self._texture_id = texture_id self._texture_id = texture_id
@ -380,8 +418,8 @@ class ImageData(BaseLayoutElement):
self._pending_pil_image = None self._pending_pil_image = None
# Clear the warning flag if we successfully created the texture # Clear the warning flag if we successfully created the texture
if hasattr(self, '_gl_context_warned'): if hasattr(self, "_gl_context_warned"):
delattr(self, '_gl_context_warned') delattr(self, "_gl_context_warned")
logger.info(f"ImageData: Successfully created texture for {self.image_path}") logger.info(f"ImageData: Successfully created texture for {self.image_path}")
return True return True
@ -390,11 +428,13 @@ class ImageData(BaseLayoutElement):
error_str = str(e) error_str = str(e)
# Check if this is a GL context error (err 1282 = GL_INVALID_OPERATION) # Check if this is a GL context error (err 1282 = GL_INVALID_OPERATION)
# These are typically caused by no GL context being current # 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 # GL context not ready - keep pending image and try again next render
# Don't spam the console with repeated messages # Don't spam the console with repeated messages
if not hasattr(self, '_gl_context_warned'): if not hasattr(self, "_gl_context_warned"):
logger.warning(f"ImageData: GL context error (1282) for {self.image_path}, will retry on next render") logger.warning(
f"ImageData: GL context error (1282) for {self.image_path}, will retry on next render"
)
self._gl_context_warned = True self._gl_context_warned = True
return False return False
else: else:
@ -415,6 +455,7 @@ class ImageData(BaseLayoutElement):
self._async_loading = False self._async_loading = False
self._async_load_requested = False self._async_load_requested = False
class PlaceholderData(BaseLayoutElement): class PlaceholderData(BaseLayoutElement):
"""Class to store placeholder data""" """Class to store placeholder data"""
@ -475,7 +516,7 @@ class PlaceholderData(BaseLayoutElement):
"rotation": self.rotation, "rotation": self.rotation,
"z_index": self.z_index, "z_index": self.z_index,
"placeholder_type": self.placeholder_type, "placeholder_type": self.placeholder_type,
"default_content": self.default_content "default_content": self.default_content,
} }
# Add base fields (v3.0+) # Add base fields (v3.0+)
data.update(self._serialize_base_fields()) data.update(self._serialize_base_fields())
@ -493,6 +534,7 @@ class PlaceholderData(BaseLayoutElement):
self.placeholder_type = data.get("placeholder_type", "image") self.placeholder_type = data.get("placeholder_type", "image")
self.default_content = data.get("default_content", "") self.default_content = data.get("default_content", "")
class TextBoxData(BaseLayoutElement): class TextBoxData(BaseLayoutElement):
"""Class to store text box data""" """Class to store text box data"""
@ -559,7 +601,7 @@ class TextBoxData(BaseLayoutElement):
"z_index": self.z_index, "z_index": self.z_index,
"text_content": self.text_content, "text_content": self.text_content,
"font_settings": self.font_settings, "font_settings": self.font_settings,
"alignment": self.alignment "alignment": self.alignment,
} }
# Add base fields (v3.0+) # Add base fields (v3.0+)
data.update(self._serialize_base_fields()) 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.font_settings = data.get("font_settings", {"family": "Arial", "size": 12, "color": (0, 0, 0)})
self.alignment = data.get("alignment", "left") self.alignment = data.get("alignment", "left")
class GhostPageData(BaseLayoutElement): class GhostPageData(BaseLayoutElement):
"""Class to represent a ghost page placeholder for alignment in double-page spreads""" """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]: def serialize(self) -> Dict[str, Any]:
"""Serialize ghost page data to dictionary""" """Serialize ghost page data to dictionary"""
data = { data = {"type": "ghostpage", "position": self.position, "size": self.size, "page_size": self.page_size}
"type": "ghostpage",
"position": self.position,
"size": self.size,
"page_size": self.page_size
}
# Add base fields (v3.0+) # Add base fields (v3.0+)
data.update(self._serialize_base_fields()) data.update(self._serialize_base_fields())
return data 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.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
from pyPhotoAlbum.snapping import SnappingSystem from pyPhotoAlbum.snapping import SnappingSystem
from pyPhotoAlbum.gl_imports import ( from pyPhotoAlbum.gl_imports import (
glBegin, glEnd, glVertex2f, glColor3f, glColor4f, glBegin,
GL_QUADS, GL_LINE_LOOP, GL_LINES, glLineWidth, glEnd,
glEnable, glDisable, GL_DEPTH_TEST, GL_BLEND, glVertex2f,
glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, 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 PageLayout:
"""Class to manage page layout and templates""" """Class to manage page layout and templates"""
@ -41,7 +54,7 @@ class PageLayout:
"""Remove a layout element from the page""" """Remove a layout element from the page"""
self.elements.remove(element) 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""" """Set a grid layout for the page"""
self.grid_layout = grid self.grid_layout = grid
@ -97,11 +110,11 @@ class PageLayout:
# For ImageData elements, request async loading if available # For ImageData elements, request async loading if available
for element in self.elements: for element in self.elements:
# Check if this is an ImageData element that needs async loading # 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 # Try to get async loader from a parent widget
if hasattr(self, '_async_loader'): if hasattr(self, "_async_loader"):
loader = 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 loader = self._parent_widget.async_image_loader
else: else:
loader = None loader = None
@ -109,8 +122,9 @@ class PageLayout:
# Request async load if loader is available and not already requested # Request async load if loader is available and not already requested
if loader and not element._async_load_requested: if loader and not element._async_load_requested:
from pyPhotoAlbum.async_backend import LoadPriority from pyPhotoAlbum.async_backend import LoadPriority
# Determine priority based on visibility (HIGH for now, can be refined) # 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) self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH)
element._async_load_requested = True element._async_load_requested = True
element._async_loading = True element._async_loading = True
@ -169,6 +183,7 @@ class PageLayout:
# Create a temporary snapping system with project settings to get snap lines # Create a temporary snapping system with project settings to get snap lines
from pyPhotoAlbum.snapping import SnappingSystem from pyPhotoAlbum.snapping import SnappingSystem
temp_snap_sys = SnappingSystem(snap_threshold_mm=snap_threshold_mm) temp_snap_sys = SnappingSystem(snap_threshold_mm=snap_threshold_mm)
temp_snap_sys.grid_size_mm = grid_size_mm temp_snap_sys.grid_size_mm = grid_size_mm
temp_snap_sys.snap_to_grid = snap_to_grid temp_snap_sys.snap_to_grid = snap_to_grid
@ -183,12 +198,12 @@ class PageLayout:
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Draw grid lines (darker gray with transparency) - visible when show_grid is enabled # 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 glColor4f(0.6, 0.6, 0.6, 0.4) # Gray with 40% opacity
glLineWidth(1.0) glLineWidth(1.0)
for orientation, position in snap_lines['grid']: for orientation, position in snap_lines["grid"]:
glBegin(GL_LINES) glBegin(GL_LINES)
if orientation == 'vertical': if orientation == "vertical":
glVertex2f(page_x + position, page_y) glVertex2f(page_x + position, page_y)
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4) glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
else: # horizontal else: # horizontal
@ -197,12 +212,12 @@ class PageLayout:
glEnd() glEnd()
# Draw guides (cyan, more visible with transparency) - only show when show_snap_lines is on # 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 glColor4f(0.0, 0.7, 0.9, 0.8) # Cyan with 80% opacity
glLineWidth(1.5) glLineWidth(1.5)
for orientation, position in snap_lines['guides']: for orientation, position in snap_lines["guides"]:
glBegin(GL_LINES) glBegin(GL_LINES)
if orientation == 'vertical': if orientation == "vertical":
glVertex2f(page_x + position, page_y) glVertex2f(page_x + position, page_y)
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4) glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
else: # horizontal else: # horizontal
@ -223,7 +238,7 @@ class PageLayout:
"elements": [elem.serialize() for elem in self.elements], "elements": [elem.serialize() for elem in self.elements],
"grid_layout": self.grid_layout.serialize() if self.grid_layout else None, "grid_layout": self.grid_layout.serialize() if self.grid_layout else None,
"snapping_system": self.snapping_system.serialize(), "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]): def deserialize(self, data: Dict[str, Any]):
@ -268,6 +283,7 @@ class PageLayout:
self.show_snap_lines = data.get("show_snap_lines", True) self.show_snap_lines = data.get("show_snap_lines", True)
class GridLayout: class GridLayout:
"""Class to manage grid layouts""" """Class to manage grid layouts"""
@ -281,7 +297,9 @@ class GridLayout:
"""Merge cells in the grid""" """Merge cells in the grid"""
self.merged_cells.append((row, col)) 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""" """Get the position of a grid cell"""
cell_width = (page_width - (self.spacing * (self.columns + 1))) / self.columns cell_width = (page_width - (self.spacing * (self.columns + 1))) / self.columns
cell_height = (page_height - (self.spacing * (self.rows + 1))) / self.rows cell_height = (page_height - (self.spacing * (self.rows + 1))) / self.rows
@ -300,12 +318,7 @@ class GridLayout:
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize grid layout to dictionary""" """Serialize grid layout to dictionary"""
return { return {"rows": self.rows, "columns": self.columns, "spacing": self.spacing, "merged_cells": self.merged_cells}
"rows": self.rows,
"columns": self.columns,
"spacing": self.spacing,
"merged_cells": self.merged_cells
}
def deserialize(self, data: Dict[str, Any]): def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary""" """Deserialize from dictionary"""

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@ from pyPhotoAlbum.version_manager import (
CURRENT_DATA_VERSION, CURRENT_DATA_VERSION,
check_version_compatibility, check_version_compatibility,
VersionCompatibility, VersionCompatibility,
DataMigration DataMigration,
) )
@ -44,7 +44,7 @@ def _import_external_images(project: Project):
# Absolute path - definitely external # Absolute path - definitely external
is_external = True is_external = True
external_path = element.image_path 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 # Relative path but not in assets folder
# Check if it exists relative to project folder # Check if it exists relative to project folder
full_path = os.path.join(project.folder_path, element.image_path) 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 original_path = element.image_path
# Skip if already a simple relative path (assets/...) # 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 continue
# Try to extract just the filename or relative path from assets folder # Try to extract just the filename or relative path from assets folder
# Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg" # Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
if '/assets/' in original_path: if "/assets/" in original_path:
parts = original_path.split('/assets/') parts = original_path.split("/assets/")
if len(parts) == 2: 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 element.image_path = new_path
normalized_count += 1 normalized_count += 1
print(f"Normalized path: {original_path} -> {new_path}") 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: try:
# Ensure .ppz extension # Ensure .ppz extension
if not zip_path.lower().endswith('.ppz'): if not zip_path.lower().endswith(".ppz"):
zip_path += '.ppz' zip_path += ".ppz"
# Check for and import any external images before saving # Check for and import any external images before saving
_import_external_images(project) _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() project_data = project.serialize()
# Add version information # Add version information
project_data['serialization_version'] = SERIALIZATION_VERSION # Legacy field project_data["serialization_version"] = SERIALIZATION_VERSION # Legacy field
project_data['data_version'] = CURRENT_DATA_VERSION # New versioning system project_data["data_version"] = CURRENT_DATA_VERSION # New versioning system
# Create ZIP file # 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 # Write project.json with stable sorting for git-friendly diffs
project_json = json.dumps(project_data, indent=2, sort_keys=True) 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 # Add all files from the assets folder
assets_folder = project.asset_manager.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) os.makedirs(extract_to, exist_ok=True)
# Extract ZIP contents # Extract ZIP contents
with zipfile.ZipFile(zip_path, 'r') as zipf: with zipfile.ZipFile(zip_path, "r") as zipf:
zipf.extractall(extract_to) zipf.extractall(extract_to)
# Load project.json # 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): if not os.path.exists(project_json_path):
raise ValueError("Invalid project file: project.json not found") 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) project_data = json.load(f)
# Check version compatibility # Check version compatibility
# Try new version field first, fall back to legacy field # 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 # Check if version is compatible
is_compatible, error_msg = check_version_compatibility(file_version, zip_path) 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}") print(f"Note: Loading project with version {file_version}, current version is {CURRENT_DATA_VERSION}")
# Create new 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) project = Project(name=project_name, folder_path=extract_to)
# Deserialize project data # 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 # Set asset resolution context for ImageData rendering
# Only set project folder - search paths are reserved for healing functionality # Only set project folder - search paths are reserved for healing functionality
from pyPhotoAlbum.models import set_asset_resolution_context from pyPhotoAlbum.models import set_asset_resolution_context
set_asset_resolution_context(extract_to) set_asset_resolution_context(extract_to)
print(f"Project loaded from {zip_path} to {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 Dictionary with project info, or None if error
""" """
try: try:
with zipfile.ZipFile(zip_path, 'r') as zipf: with zipfile.ZipFile(zip_path, "r") as zipf:
# Read project.json # 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) project_data = json.loads(project_json)
return { return {
'name': project_data.get('name', 'Unknown'), "name": project_data.get("name", "Unknown"),
'version': project_data.get('serialization_version', 'Unknown'), "version": project_data.get("serialization_version", "Unknown"),
'page_count': len(project_data.get('pages', [])), "page_count": len(project_data.get("pages", [])),
'page_size_mm': project_data.get('page_size_mm', (0, 0)), "page_size_mm": project_data.get("page_size_mm", (0, 0)),
'working_dpi': project_data.get('working_dpi', 300), "working_dpi": project_data.get("working_dpi", 300),
} }
except Exception as e: except Exception as e:
print(f"Error reading project info: {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) attr = getattr(window_class, attr_name)
# Check if this attribute has ribbon action metadata # Check if this attribute has ribbon action metadata
if hasattr(attr, '_ribbon_action'): if hasattr(attr, "_ribbon_action"):
action_data = attr._ribbon_action action_data = attr._ribbon_action
# Extract tab and group information # Extract tab and group information
tab_name = action_data['tab'] tab_name = action_data["tab"]
group_name = action_data['group'] group_name = action_data["group"]
# Add action to the appropriate tab and group # Add action to the appropriate tab and group
tabs[tab_name][group_name].append({ tabs[tab_name][group_name].append(
'label': action_data['label'], {
'action': action_data['action'], "label": action_data["label"],
'tooltip': action_data['tooltip'], "action": action_data["action"],
'icon': action_data.get('icon'), "tooltip": action_data["tooltip"],
'shortcut': action_data.get('shortcut'), "icon": action_data.get("icon"),
}) "shortcut": action_data.get("shortcut"),
}
)
except (AttributeError, TypeError): except (AttributeError, TypeError):
# Skip attributes that can't be inspected # Skip attributes that can't be inspected
continue continue
@ -72,7 +74,7 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
ribbon_config = {} ribbon_config = {}
# Define tab order (tabs will appear in this order) # 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 # Add tabs in the defined order, then add any remaining tabs
all_tabs = list(tabs.keys()) 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) # Define group order per tab (if needed)
group_orders = { group_orders = {
'Home': ['File', 'Edit'], "Home": ["File", "Edit"],
'Insert': ['Media'], "Insert": ["Media"],
'Layout': ['Navigation', 'Page', 'Templates'], "Layout": ["Navigation", "Page", "Templates"],
'Arrange': ['Align', 'Size', 'Distribute'], "Arrange": ["Align", "Size", "Distribute"],
'View': ['Zoom'], "View": ["Zoom"],
'Export': ['Export'], "Export": ["Export"],
} }
# Get the group order for this tab, or use alphabetical # 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: for group_name in group_order:
if group_name in groups_dict: if group_name in groups_dict:
actions = groups_dict[group_name] actions = groups_dict[group_name]
groups_list.append({ groups_list.append({"name": group_name, "actions": actions})
'name': group_name,
'actions': actions
})
ribbon_config[tab_name] = { ribbon_config[tab_name] = {"groups": groups_list}
'groups': groups_list
}
return ribbon_config return ribbon_config
@ -136,12 +133,12 @@ def get_keyboard_shortcuts(window_class: Type) -> Dict[str, str]:
try: try:
attr = getattr(window_class, attr_name) attr = getattr(window_class, attr_name)
if hasattr(attr, '_ribbon_action'): if hasattr(attr, "_ribbon_action"):
action_data = attr._ribbon_action action_data = attr._ribbon_action
shortcut = action_data.get('shortcut') shortcut = action_data.get("shortcut")
if shortcut: if shortcut:
shortcuts[shortcut] = action_data['action'] shortcuts[shortcut] = action_data["action"]
except (AttributeError, TypeError): except (AttributeError, TypeError):
continue 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") errors.append(f"Tab '{tab_name}' data must be a dictionary")
continue continue
if 'groups' not in tab_data: if "groups" not in tab_data:
errors.append(f"Tab '{tab_name}' missing 'groups' key") errors.append(f"Tab '{tab_name}' missing 'groups' key")
continue continue
groups = tab_data['groups'] groups = tab_data["groups"]
if not isinstance(groups, list): if not isinstance(groups, list):
errors.append(f"Tab '{tab_name}' groups must be a list") errors.append(f"Tab '{tab_name}' groups must be a list")
continue 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") errors.append(f"Tab '{tab_name}' group {i} must be a dictionary")
continue continue
if 'name' not in group: if "name" not in group:
errors.append(f"Tab '{tab_name}' group {i} missing 'name'") 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'") errors.append(f"Tab '{tab_name}' group {i} missing 'actions'")
continue continue
actions = group['actions'] actions = group["actions"]
if not isinstance(actions, list): if not isinstance(actions, list):
errors.append(f"Tab '{tab_name}' group {i} actions must be a list") errors.append(f"Tab '{tab_name}' group {i} actions must be a list")
continue 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") errors.append(f"Tab '{tab_name}' group {i} action {j} must be a dictionary")
continue continue
required_keys = ['label', 'action', 'tooltip'] required_keys = ["label", "action", "tooltip"]
for key in required_keys: for key in required_keys:
if key not in action: if key not in action:
errors.append( errors.append(f"Tab '{tab_name}' group {i} action {j} missing '{key}'")
f"Tab '{tab_name}' group {i} action {j} missing '{key}'"
)
return errors return errors
@ -220,12 +215,8 @@ def print_ribbon_summary(config: Dict[str, Any]):
print("\n=== Ribbon Configuration Summary ===\n") print("\n=== Ribbon Configuration Summary ===\n")
total_tabs = len(config) total_tabs = len(config)
total_groups = sum(len(tab_data['groups']) for tab_data in config.values()) total_groups = sum(len(tab_data["groups"]) for tab_data in config.values())
total_actions = sum( total_actions = sum(len(group["actions"]) for tab_data in config.values() for group in tab_data["groups"])
len(group['actions'])
for tab_data in config.values()
for group in tab_data['groups']
)
print(f"Total Tabs: {total_tabs}") print(f"Total Tabs: {total_tabs}")
print(f"Total Groups: {total_groups}") 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(): for tab_name, tab_data in config.items():
print(f"📑 {tab_name}") print(f"📑 {tab_name}")
for group in tab_data['groups']: for group in tab_data["groups"]:
print(f" 📦 {group['name']} ({len(group['actions'])} actions)") print(f" 📦 {group['name']} ({len(group['actions'])} actions)")
for action in group['actions']: for action in group["actions"]:
shortcut = f" ({action['shortcut']})" if action.get('shortcut') else "" shortcut = f" ({action['shortcut']})" if action.get("shortcut") else ""
print(f"{action['label']}{shortcut}") print(f"{action['label']}{shortcut}")
print() print()

View File

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

View File

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

View File

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

View File

@ -3,8 +3,15 @@ Text editing dialog for pyPhotoAlbum
""" """
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QDialog,
QTextEdit, QLabel, QComboBox, QSpinBox, QColorDialog QVBoxLayout,
QHBoxLayout,
QPushButton,
QTextEdit,
QLabel,
QComboBox,
QSpinBox,
QColorDialog,
) )
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFont, QColor from PyQt6.QtGui import QFont, QColor
@ -42,10 +49,9 @@ class TextEditDialog(QDialog):
# Font family # Font family
font_layout.addWidget(QLabel("Font:")) font_layout.addWidget(QLabel("Font:"))
self.font_combo = QComboBox() self.font_combo = QComboBox()
self.font_combo.addItems([ self.font_combo.addItems(
"Arial", "Times New Roman", "Courier New", ["Arial", "Times New Roman", "Courier New", "Helvetica", "Verdana", "Georgia", "Comic Sans MS"]
"Helvetica", "Verdana", "Georgia", "Comic Sans MS" )
])
font_layout.addWidget(self.font_combo) font_layout.addWidget(self.font_combo)
# Font size # Font size
@ -96,26 +102,22 @@ class TextEditDialog(QDialog):
self.text_edit.setPlainText(self.text_element.text_content) self.text_edit.setPlainText(self.text_element.text_content)
# Load font settings # 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) index = self.font_combo.findText(font_family)
if index >= 0: if index >= 0:
self.font_combo.setCurrentIndex(index) 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)) self.font_size_spin.setValue(int(font_size))
# Load color # 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): if all(isinstance(c, int) and c > 1 for c in color):
# Color in 0-255 range # Color in 0-255 range
self.current_color = QColor(*color) self.current_color = QColor(*color)
else: else:
# Color in 0-1 range # Color in 0-1 range
self.current_color = QColor( self.current_color = QColor(int(color[0] * 255), int(color[1] * 255), int(color[2] * 255))
int(color[0] * 255),
int(color[1] * 255),
int(color[2] * 255)
)
self._update_color_button() self._update_color_button()
# Load alignment # Load alignment
@ -141,15 +143,11 @@ class TextEditDialog(QDialog):
def get_values(self): def get_values(self):
"""Get the edited values""" """Get the edited values"""
return { return {
'text_content': self.text_edit.toPlainText(), "text_content": self.text_edit.toPlainText(),
'font_settings': { "font_settings": {
'family': self.font_combo.currentText(), "family": self.font_combo.currentText(),
'size': self.font_size_spin.value(), "size": self.font_size_spin.value(),
'color': ( "color": (self.current_color.red(), self.current_color.green(), self.current_color.blue()),
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", "released": "2025-01-11",
"breaking_changes": [ "breaking_changes": [
"Asset paths changed from absolute/full-project-relative to project-relative", "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 "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)", "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 "compatible_with": ["1.0", "2.0", "3.0"], # 3.0 can read older versions with migration
} },
} }
@ -120,9 +120,11 @@ class DataMigration:
@classmethod @classmethod
def register_migration(cls, from_version: str, to_version: str): def register_migration(cls, from_version: str, to_version: str):
"""Decorator to register a migration function""" """Decorator to register a migration function"""
def decorator(func): def decorator(func):
cls._migrations[(from_version, to_version)] = func cls._migrations[(from_version, to_version)] = func
return func return func
return decorator return decorator
@classmethod @classmethod
@ -164,6 +166,7 @@ class DataMigration:
# Register migrations # Register migrations
@DataMigration.register_migration("1.0", "2.0") @DataMigration.register_migration("1.0", "2.0")
def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]: 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") print("Migration 1.0 → 2.0: Asset paths will be normalized during load")
# Update version in data # Update version in data
data['data_version'] = "2.0" data["data_version"] = "2.0"
# Note: Actual path normalization is handled in load_from_zip # Note: Actual path normalization is handled in load_from_zip
# This migration mainly updates the version number # 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 element_data["deleted_at"] = None
# Update version # Update version
data['data_version'] = "3.0" data["data_version"] = "3.0"
print(f" Migrated {len(data.get('pages', []))} pages to v3.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"File version info:\n"
error_msg += f" Description: {file_info.get('description', 'Unknown')}\n" error_msg += f" Description: {file_info.get('description', 'Unknown')}\n"
error_msg += f" Released: {file_info.get('released', '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: if breaking_changes:
error_msg += f" Breaking changes:\n" error_msg += f" Breaking changes:\n"
for change in breaking_changes: 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" Description: {version_info.get('description', 'Unknown')}")
info.append(f" Released: {version_info.get('released', '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: if breaking_changes:
info.append(f" Breaking changes:") info.append(f" Breaking changes:")
for change in breaking_changes: for change in breaking_changes:

View File

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

View File

@ -469,11 +469,11 @@ class TestAlignmentManager:
# Elements should not overlap (min_gap = 2.0) # Elements should not overlap (min_gap = 2.0)
gap_x = max( gap_x = max(
elem2.position[0] - (elem1.position[0] + elem1.size[0]), 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( gap_y = max(
elem2.position[1] - (elem1.position[1] + elem1.size[1]), 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 # Either horizontal or vertical gap should be >= min_gap
@ -510,11 +510,11 @@ class TestAlignmentManager:
# Calculate gaps between rectangles # Calculate gaps between rectangles
gap_x = max( gap_x = max(
elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]), 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( gap_y = max(
elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]), 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 # At least one gap should be >= min_gap
@ -564,11 +564,7 @@ class TestAlignmentManager:
elem4 = ImageData(x=140, y=90, width=10, height=10) elem4 = ImageData(x=140, y=90, width=10, height=10)
page_size = (160, 110) page_size = (160, 110)
changes = AlignmentManager.maximize_pattern( changes = AlignmentManager.maximize_pattern([elem1, elem2, elem3, elem4], page_size, min_gap=2.0)
[elem1, elem2, elem3, elem4],
page_size,
min_gap=2.0
)
# All elements should grow # All elements should grow
for elem in [elem1, elem2, elem3, elem4]: for elem in [elem1, elem2, elem3, elem4]:
@ -584,11 +580,11 @@ class TestAlignmentManager:
gap_x = max( gap_x = max(
elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]), 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( gap_y = max(
elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]), 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 assert gap_x >= 2.0 or gap_y >= 2.0

View File

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

View File

@ -28,8 +28,8 @@ class TestAssetDropWidget(AssetDropMixin, AssetPathMixin, PageNavigationMixin, V
def _get_project_folder(self): def _get_project_folder(self):
"""Override to access project via window mock""" """Override to access project via window mock"""
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'project') and main_window.project: if hasattr(main_window, "project") and main_window.project:
return getattr(main_window.project, 'folder_path', None) return getattr(main_window.project, "folder_path", None)
return None return None
@ -43,7 +43,7 @@ class TestAssetDropInitialization:
# Should accept drops (set in GLWidget.__init__) # Should accept drops (set in GLWidget.__init__)
# This is a property of the widget, not the mixin # This is a property of the widget, not the mixin
assert hasattr(widget, 'acceptDrops') assert hasattr(widget, "acceptDrops")
class TestDragEnterEvent: class TestDragEnterEvent:
@ -141,7 +141,7 @@ class TestDragMoveEvent:
class TestDropEvent: class TestDropEvent:
"""Test dropEvent method""" """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): def test_drop_creates_image_element(self, mock_cmd_class, qtbot):
"""Test dropping image file creates ImageData element""" """Test dropping image file creates ImageData element"""
widget = TestAssetDropWidget() widget = TestAssetDropWidget()
@ -239,7 +239,7 @@ class TestDropEvent:
# Create a real test image file # Create a real test image file
test_image = tmp_path / "test_image.jpg" 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 # Setup project with page containing placeholder
mock_window = Mock() mock_window = Mock()
@ -248,6 +248,7 @@ class TestDropEvent:
page = Page(layout=PageLayout(width=210, height=297), page_number=1) page = Page(layout=PageLayout(width=210, height=297), page_number=1)
from pyPhotoAlbum.models import PlaceholderData from pyPhotoAlbum.models import PlaceholderData
placeholder = PlaceholderData(x=100, y=100, width=200, height=150) placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
page.layout.elements.append(placeholder) page.layout.elements.append(placeholder)
@ -280,7 +281,7 @@ class TestDropEvent:
# Image path should now be in assets folder (imported) # Image path should now be in assets folder (imported)
assert page.layout.elements[0].image_path.startswith("assets/") 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): def test_drop_multiple_files(self, mock_cmd_class, qtbot):
"""Test dropping first image from multiple files""" """Test dropping first image from multiple files"""
widget = TestAssetDropWidget() widget = TestAssetDropWidget()
@ -311,11 +312,13 @@ class TestDropEvent:
# Create drop event with multiple files (only first is used) # Create drop event with multiple files (only first is used)
mime_data = QMimeData() mime_data = QMimeData()
mime_data.setUrls([ mime_data.setUrls(
[
QUrl.fromLocalFile("/path/to/image1.jpg"), QUrl.fromLocalFile("/path/to/image1.jpg"),
QUrl.fromLocalFile("/path/to/image2.png"), QUrl.fromLocalFile("/path/to/image2.png"),
QUrl.fromLocalFile("/path/to/image3.jpg") QUrl.fromLocalFile("/path/to/image3.jpg"),
]) ]
)
event = Mock() event = Mock()
event.mimeData = Mock(return_value=mime_data) event.mimeData = Mock(return_value=mime_data)
@ -364,7 +367,7 @@ class TestDropEvent:
# Create a real test image file # Create a real test image file
test_image = tmp_path / "new_image.jpg" 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 # Setup project with page containing existing ImageData
mock_window = Mock() mock_window = Mock()
@ -372,10 +375,7 @@ class TestDropEvent:
mock_window.project.working_dpi = 96 mock_window.project.working_dpi = 96
page = Page(layout=PageLayout(width=210, height=297), page_number=1) page = Page(layout=PageLayout(width=210, height=297), page_number=1)
existing_image = ImageData( existing_image = ImageData(image_path="assets/old_image.jpg", x=100, y=100, width=200, height=150)
image_path="assets/old_image.jpg",
x=100, y=100, width=200, height=150
)
page.layout.elements.append(existing_image) page.layout.elements.append(existing_image)
mock_window.project.pages = [page] mock_window.project.pages = [page]
@ -407,24 +407,19 @@ class TestDropEvent:
widget.update = Mock() widget.update = Mock()
test_image = tmp_path / "test.jpg" 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 = Mock()
mock_window.project = Project(name="Test") mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1) page = Page(layout=PageLayout(width=210, height=297), page_number=1)
existing_image = ImageData( existing_image = ImageData(image_path="assets/old.jpg", x=100, y=100, width=200, height=150)
image_path="assets/old.jpg",
x=100, y=100, width=200, height=150
)
page.layout.elements.append(existing_image) page.layout.elements.append(existing_image)
mock_window.project.pages = [page] mock_window.project.pages = [page]
# Mock asset manager to raise exception # Mock asset manager to raise exception
mock_window.project.asset_manager = Mock() mock_window.project.asset_manager = Mock()
mock_window.project.asset_manager.import_asset = Mock( mock_window.project.asset_manager.import_asset = Mock(side_effect=Exception("Import failed"))
side_effect=Exception("Import failed")
)
widget.window = Mock(return_value=mock_window) widget.window = Mock(return_value=mock_window)
widget._get_element_at = Mock(return_value=existing_image) widget._get_element_at = Mock(return_value=existing_image)
@ -454,7 +449,7 @@ class TestDropEvent:
# Create a corrupted/invalid image file # Create a corrupted/invalid image file
corrupted_image = tmp_path / "corrupted.jpg" 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 = Mock()
mock_window.project = Project(name="Test") mock_window.project = Project(name="Test")
@ -488,7 +483,8 @@ class TestDropEvent:
# Should use default dimensions (200, 150) from _calculate_image_dimensions # Should use default dimensions (200, 150) from _calculate_image_dimensions
# Check that AddElementCommand was called with an ImageData # Check that AddElementCommand was called with an ImageData
from pyPhotoAlbum.commands import AddElementCommand 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 # Re-run to check the call
widget.dropEvent(event) widget.dropEvent(event)
assert mock_cmd.called assert mock_cmd.called
@ -527,10 +523,7 @@ class TestExtractImagePathEdgeCases:
widget.update = Mock() widget.update = Mock()
mime_data = QMimeData() mime_data = QMimeData()
mime_data.setUrls([ mime_data.setUrls([QUrl.fromLocalFile("/path/to/document.pdf"), QUrl.fromLocalFile("/path/to/file.txt")])
QUrl.fromLocalFile("/path/to/document.pdf"),
QUrl.fromLocalFile("/path/to/file.txt")
])
event = Mock() event = Mock()
event.mimeData = Mock(return_value=mime_data) event.mimeData = Mock(return_value=mime_data)
@ -576,7 +569,7 @@ class TestPlaceholderReplacementEdgeCases:
widget.update = Mock() widget.update = Mock()
test_image = tmp_path / "test.jpg" 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 # Setup project WITHOUT pages
mock_window = Mock() mock_window = Mock()
@ -585,6 +578,7 @@ class TestPlaceholderReplacementEdgeCases:
mock_window.project.pages = [] # Empty pages list mock_window.project.pages = [] # Empty pages list
from pyPhotoAlbum.models import PlaceholderData from pyPhotoAlbum.models import PlaceholderData
placeholder = PlaceholderData(x=100, y=100, width=200, height=150) placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
mock_window.project.asset_manager = Mock() 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) qtbot.addWidget(window)
mock_critical = Mock() mock_critical = Mock()
monkeypatch.setattr(QMessageBox, 'critical', mock_critical) monkeypatch.setattr(QMessageBox, "critical", mock_critical)
window.show_error("Error Title", "Error message") window.show_error("Error Title", "Error message")
@ -260,7 +260,7 @@ class TestDialogMethods:
qtbot.addWidget(window) qtbot.addWidget(window)
mock_warning = Mock() mock_warning = Mock()
monkeypatch.setattr(QMessageBox, 'warning', mock_warning) monkeypatch.setattr(QMessageBox, "warning", mock_warning)
window.show_warning("Warning Title", "Warning message") window.show_warning("Warning Title", "Warning message")
@ -271,7 +271,7 @@ class TestDialogMethods:
qtbot.addWidget(window) qtbot.addWidget(window)
mock_info = Mock() mock_info = Mock()
monkeypatch.setattr(QMessageBox, 'information', mock_info) monkeypatch.setattr(QMessageBox, "information", mock_info)
window.show_info("Info Title", "Info message") window.show_info("Info Title", "Info message")
@ -309,7 +309,7 @@ class TestRequirePage:
window._gl_widget = Mock() window._gl_widget = Mock()
mock_warning = Mock() mock_warning = Mock()
monkeypatch.setattr(QMessageBox, 'warning', mock_warning) monkeypatch.setattr(QMessageBox, "warning", mock_warning)
result = window.require_page(show_warning=True) result = window.require_page(show_warning=True)
@ -366,7 +366,7 @@ class TestRequireSelection:
window._gl_widget = gl_widget window._gl_widget = gl_widget
mock_info = Mock() 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) result = window.require_selection(min_count=1, show_warning=True)
@ -386,7 +386,7 @@ class TestRequireSelection:
window._gl_widget = gl_widget window._gl_widget = gl_widget
mock_info = Mock() 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) result = window.require_selection(min_count=3, show_warning=True)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,7 +28,7 @@ class TestMouseInteractionWidget(
ElementSelectionMixin, ElementSelectionMixin,
ViewportMixin, ViewportMixin,
UndoableInteractionMixin, UndoableInteractionMixin,
QOpenGLWidget QOpenGLWidget,
): ):
"""Test widget combining mouse interaction with other required mixins""" """Test widget combining mouse interaction with other required mixins"""
@ -49,9 +49,9 @@ class TestMouseInteractionInitialization:
qtbot.addWidget(widget) qtbot.addWidget(widget)
# Should have initialized state # Should have initialized state
assert hasattr(widget, 'drag_start_pos') assert hasattr(widget, "drag_start_pos")
assert hasattr(widget, 'is_dragging') assert hasattr(widget, "is_dragging")
assert hasattr(widget, 'is_panning') assert hasattr(widget, "is_panning")
assert widget.drag_start_pos is None assert widget.drag_start_pos is None
assert widget.is_dragging is False assert widget.is_dragging is False
assert widget.is_panning is False assert widget.is_panning is False
@ -123,8 +123,11 @@ class TestMousePressEvent:
# Create image element with crop info # Create image element with crop info
element = ImageData( element = ImageData(
image_path="/test.jpg", image_path="/test.jpg",
x=50, y=50, width=100, height=100, x=50,
crop_info=(0.0, 0.0, 1.0, 1.0) # crop_info is a tuple (x, y, width, height) 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() event = Mock()
@ -205,7 +208,7 @@ class TestMouseMoveEvent:
event.buttons = Mock(return_value=Qt.MouseButton.NoButton) event.buttons = Mock(return_value=Qt.MouseButton.NoButton)
# Mock resize handle detection # 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._get_element_at = Mock(return_value=element)
widget.mouseMoveEvent(event) widget.mouseMoveEvent(event)
@ -295,9 +298,11 @@ class TestMouseMoveEvent:
# Create image element with crop info # Create image element with crop info
element = ImageData( element = ImageData(
image_path="/test.jpg", image_path="/test.jpg",
x=100, y=100, x=100,
width=100, height=100, y=100,
crop_info=(0.0, 0.0, 1.0, 1.0) # crop_info is a tuple (x, y, width, height) 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) widget.selected_elements.add(element)
@ -377,9 +382,11 @@ class TestMouseDoubleClickEvent:
# Create text element with correct constructor # Create text element with correct constructor
text_element = TextBoxData( text_element = TextBoxData(
text_content="Test", text_content="Test",
x=100, y=100, x=100,
width=100, height=50, y=100,
font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)} width=100,
height=50,
font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)},
) )
# Mock _edit_text_element method # Mock _edit_text_element method
@ -394,7 +401,7 @@ class TestMouseDoubleClickEvent:
QPointF(125, 125), QPointF(125, 125),
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier Qt.KeyboardModifier.NoModifier,
) )
widget.mouseDoubleClickEvent(event) widget.mouseDoubleClickEvent(event)
@ -419,7 +426,7 @@ class TestMouseDoubleClickEvent:
QPointF(125, 125), QPointF(125, 125),
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier Qt.KeyboardModifier.NoModifier,
) )
widget.mouseDoubleClickEvent(event) 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 # Create a minimal test widget class that doesn't require full GLWidget initialization
class MultiSelectTestWidget(ElementSelectionMixin, RenderingMixin, QOpenGLWidget): class MultiSelectTestWidget(ElementSelectionMixin, RenderingMixin, QOpenGLWidget):
"""Widget combining necessary mixins for multiselect testing""" """Widget combining necessary mixins for multiselect testing"""
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._page_renderers = [] self._page_renderers = []
@ -69,7 +70,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 1: Single selection") print("\nTest 1: Single selection")
widget.selected_elements = {element1} 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) # Simulate paintGL call (only the relevant part)
for selected_elem in widget.selected_elements: for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem) widget._draw_selection_handles(selected_elem)
@ -82,7 +83,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 2: Multiple selection (2 elements)") print("\nTest 2: Multiple selection (2 elements)")
widget.selected_elements = {element1, element2} 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: for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem) widget._draw_selection_handles(selected_elem)
@ -95,7 +96,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 3: Multiple selection (3 elements)") print("\nTest 3: Multiple selection (3 elements)")
widget.selected_elements = {element1, element2, element3} 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: for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem) widget._draw_selection_handles(selected_elem)
@ -108,7 +109,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 4: No selection") print("\nTest 4: No selection")
widget.selected_elements = set() 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: for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem) widget._draw_selection_handles(selected_elem)
@ -120,15 +121,17 @@ def test_multiselect_visual_feedback(qtbot):
widget.selected_elements = {element2} widget.selected_elements = {element2}
# Mock OpenGL functions # Mock OpenGL functions
with patch('pyPhotoAlbum.gl_widget.glColor3f'), \ with (
patch('pyPhotoAlbum.gl_widget.glLineWidth'), \ patch("pyPhotoAlbum.gl_widget.glColor3f"),
patch('pyPhotoAlbum.gl_widget.glBegin'), \ patch("pyPhotoAlbum.gl_widget.glLineWidth"),
patch('pyPhotoAlbum.gl_widget.glEnd'), \ patch("pyPhotoAlbum.gl_widget.glBegin"),
patch('pyPhotoAlbum.gl_widget.glVertex2f'), \ patch("pyPhotoAlbum.gl_widget.glEnd"),
patch('pyPhotoAlbum.gl_widget.glPushMatrix'), \ patch("pyPhotoAlbum.gl_widget.glVertex2f"),
patch('pyPhotoAlbum.gl_widget.glPopMatrix'), \ patch("pyPhotoAlbum.gl_widget.glPushMatrix"),
patch('pyPhotoAlbum.gl_widget.glTranslatef'), \ patch("pyPhotoAlbum.gl_widget.glPopMatrix"),
patch('pyPhotoAlbum.gl_widget.glRotatef'): patch("pyPhotoAlbum.gl_widget.glTranslatef"),
patch("pyPhotoAlbum.gl_widget.glRotatef"),
):
# Call the actual method # Call the actual method
widget._draw_selection_handles(element2) widget._draw_selection_handles(element2)

View File

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

View File

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

View File

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

View File

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

View File

@ -190,16 +190,16 @@ class TestPageSetupDialog:
values = dialog.get_values() values = dialog.get_values()
# Check all values returned # Check all values returned
assert values['selected_index'] == 0 assert values["selected_index"] == 0
assert values['selected_page'] == page assert values["selected_page"] == page
assert values['is_cover'] is True assert values["is_cover"] is True
assert values['paper_thickness_mm'] == 0.15 assert values["paper_thickness_mm"] == 0.15
assert values['cover_bleed_mm'] == 5.0 assert values["cover_bleed_mm"] == 5.0
assert values['width_mm'] == 200 assert values["width_mm"] == 200
assert values['height_mm'] == 280 assert values["height_mm"] == 280
assert values['working_dpi'] == 150 assert values["working_dpi"] == 150
assert values['export_dpi'] == 600 assert values["export_dpi"] == 600
assert values['set_as_default'] is True assert values["set_as_default"] is True
def test_dialog_page_change_updates_values(self, qtbot): def test_dialog_page_change_updates_values(self, qtbot):
"""Test changing selected page updates displayed values""" """Test changing selected page updates displayed values"""
@ -240,14 +240,14 @@ class TestDialogMixin:
# Create mock dialog with get_values as a proper method # Create mock dialog with get_values as a proper method
mock_dialog = MagicMock(spec=QDialog) mock_dialog = MagicMock(spec=QDialog)
mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted) 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_dialog_class = Mock(return_value=mock_dialog) mock_dialog_class = Mock(return_value=mock_dialog)
result = window.create_dialog(mock_dialog_class) result = window.create_dialog(mock_dialog_class)
assert result == {'test': 'value'} assert result == {"test": "value"}
mock_dialog.exec.assert_called_once() mock_dialog.exec.assert_called_once()
def test_dialog_mixin_create_dialog_rejected(self, qtbot): 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 # Create mock dialog with get_values as a proper method
mock_dialog = MagicMock(spec=QDialog) mock_dialog = MagicMock(spec=QDialog)
mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted) 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_dialog_class = Mock(return_value=mock_dialog) mock_dialog_class = Mock(return_value=mock_dialog)
@ -294,7 +294,7 @@ class TestDialogMixin:
result = window.show_dialog(mock_dialog_class, on_accept=callback) result = window.show_dialog(mock_dialog_class, on_accept=callback)
assert result is True assert result is True
callback.assert_called_once_with({'test': 'value'}) callback.assert_called_once_with({"test": "value"})
class TestDialogActionDecorator: class TestDialogActionDecorator:
@ -362,7 +362,7 @@ class TestDialogMixinEdgeCases:
mock_dialog = MagicMock(spec=QDialog) mock_dialog = MagicMock(spec=QDialog)
mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted) 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) mock_dialog_class = Mock(return_value=mock_dialog)
@ -370,7 +370,7 @@ class TestDialogMixinEdgeCases:
# Verify setWindowTitle was called # Verify setWindowTitle was called
mock_dialog.setWindowTitle.assert_called_once_with("Custom Title") mock_dialog.setWindowTitle.assert_called_once_with("Custom Title")
assert result == {'data': 'test'} assert result == {"data": "test"}
def test_show_dialog_rejected(self, qtbot): def test_show_dialog_rejected(self, qtbot):
"""Test show_dialog when user rejects dialog""" """Test show_dialog when user rejects dialog"""
@ -543,16 +543,16 @@ class TestPageSetupIntegration:
# Create mock values that would come from dialog # Create mock values that would come from dialog
values = { values = {
'selected_index': 0, "selected_index": 0,
'selected_page': window.project.pages[0], "selected_page": window.project.pages[0],
'is_cover': False, "is_cover": False,
'paper_thickness_mm': 0.15, "paper_thickness_mm": 0.15,
'cover_bleed_mm': 5.0, "cover_bleed_mm": 5.0,
'width_mm': 200, "width_mm": 200,
'height_mm': 280, "height_mm": 280,
'working_dpi': 150, "working_dpi": 150,
'export_dpi': 600, "export_dpi": 600,
'set_as_default': True "set_as_default": True,
} }
# Access the unwrapped function to test business logic directly # Access the unwrapped function to test business logic directly
@ -564,7 +564,7 @@ class TestPageSetupIntegration:
original_func = window.page_setup original_func = window.page_setup
# Decorators return wrappers, but we can call them with values directly # Decorators return wrappers, but we can call them with values directly
# by accessing the innermost wrapped function # by accessing the innermost wrapped function
while hasattr(original_func, '__wrapped__'): while hasattr(original_func, "__wrapped__"):
original_func = original_func.__wrapped__ original_func = original_func.__wrapped__
# If no __wrapped__, the decorator system is different # If no __wrapped__, the decorator system is different
@ -575,7 +575,7 @@ class TestPageSetupIntegration:
# Get the undecorated method from the class # Get the undecorated method from the class
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
# Find the innermost function # Find the innermost function
while hasattr(undecorated_page_setup, '__wrapped__'): while hasattr(undecorated_page_setup, "__wrapped__"):
undecorated_page_setup = undecorated_page_setup.__wrapped__ undecorated_page_setup = undecorated_page_setup.__wrapped__
# Call the business logic directly # Call the business logic directly
@ -635,22 +635,23 @@ class TestPageSetupIntegration:
# Test designating first page as cover # Test designating first page as cover
values = { values = {
'selected_index': 0, "selected_index": 0,
'selected_page': window.project.pages[0], "selected_page": window.project.pages[0],
'is_cover': True, # Designate as cover "is_cover": True, # Designate as cover
'paper_thickness_mm': 0.1, "paper_thickness_mm": 0.1,
'cover_bleed_mm': 3.0, "cover_bleed_mm": 3.0,
'width_mm': 210, "width_mm": 210,
'height_mm': 297, "height_mm": 297,
'working_dpi': 96, "working_dpi": 96,
'export_dpi': 300, "export_dpi": 300,
'set_as_default': False "set_as_default": False,
} }
# Get the undecorated method # Get the undecorated method
from pyPhotoAlbum.mixins.operations import page_ops from pyPhotoAlbum.mixins.operations import page_ops
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup 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 = undecorated_page_setup.__wrapped__
# Mock update_cover_dimensions # Mock update_cover_dimensions
@ -706,21 +707,22 @@ class TestPageSetupIntegration:
# Test changing double spread page size # Test changing double spread page size
values = { values = {
'selected_index': 0, "selected_index": 0,
'selected_page': window.project.pages[0], "selected_page": window.project.pages[0],
'is_cover': False, "is_cover": False,
'paper_thickness_mm': 0.1, "paper_thickness_mm": 0.1,
'cover_bleed_mm': 3.0, "cover_bleed_mm": 3.0,
'width_mm': 200, # New base width "width_mm": 200, # New base width
'height_mm': 280, # New height "height_mm": 280, # New height
'working_dpi': 96, "working_dpi": 96,
'export_dpi': 300, "export_dpi": 300,
'set_as_default': False "set_as_default": False,
} }
from pyPhotoAlbum.mixins.operations import page_ops from pyPhotoAlbum.mixins.operations import page_ops
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup 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 = undecorated_page_setup.__wrapped__
undecorated_page_setup(window, values) 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 # We can verify the class signature and that it would accept these params
# This is a structural test rather than a full initialization test # 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 # The actual widget creation tests are in test_page_setup_dialog.py
# using qtbot which handles Qt properly # using qtbot which handles Qt properly
@ -44,7 +44,7 @@ class TestPageSetupDialogWithMocks:
project.pages = [page1, page2] project.pages = [page1, page2]
# Mock the dialog instance # 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) dialog = PageSetupDialog(None, None, 0)
# Manually set required attributes # Manually set required attributes
@ -88,7 +88,7 @@ class TestPageSetupDialogWithMocks:
page = Page(layout=PageLayout(width=210, height=297), page_number=1) page = Page(layout=PageLayout(width=210, height=297), page_number=1)
project.pages = [page] 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 = PageSetupDialog(None, None, 0)
dialog.project = project dialog.project = project
dialog._cover_group = Mock() dialog._cover_group = Mock()
@ -116,7 +116,7 @@ class TestPageSetupDialogWithMocks:
page.is_cover = False page.is_cover = False
project.pages.append(page) 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 = PageSetupDialog(None, None, 0)
dialog.project = project dialog.project = project
dialog.cover_checkbox = Mock() dialog.cover_checkbox = Mock()
@ -158,7 +158,7 @@ class TestPageSetupDialogWithMocks:
page = Page(layout=PageLayout(width=210, height=297), page_number=1) page = Page(layout=PageLayout(width=210, height=297), page_number=1)
project.pages = [page] 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 = PageSetupDialog(None, None, 0)
dialog.project = project dialog.project = project
@ -194,16 +194,16 @@ class TestPageSetupDialogWithMocks:
values = dialog.get_values() values = dialog.get_values()
# Verify all values were extracted # Verify all values were extracted
assert values['selected_index'] == 0 assert values["selected_index"] == 0
assert values['selected_page'] == page assert values["selected_page"] == page
assert values['is_cover'] is True assert values["is_cover"] is True
assert values['paper_thickness_mm'] == 0.15 assert values["paper_thickness_mm"] == 0.15
assert values['cover_bleed_mm'] == 5.0 assert values["cover_bleed_mm"] == 5.0
assert values['width_mm'] == 200.0 assert values["width_mm"] == 200.0
assert values['height_mm'] == 280.0 assert values["height_mm"] == 280.0
assert values['working_dpi'] == 150 assert values["working_dpi"] == 150
assert values['export_dpi'] == 600 assert values["export_dpi"] == 600
assert values['set_as_default'] is True assert values["set_as_default"] is True
def test_cover_page_width_display(self): def test_cover_page_width_display(self):
"""Test cover page shows full width, not base width""" """Test cover page shows full width, not base width"""
@ -217,7 +217,7 @@ class TestPageSetupDialogWithMocks:
page.is_cover = True page.is_cover = True
project.pages = [page] 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 = PageSetupDialog(None, None, 0)
dialog.project = project dialog.project = project
dialog._cover_group = Mock() dialog._cover_group = Mock()
@ -259,7 +259,7 @@ class TestDialogMixinMocked:
# Mock dialog class # Mock dialog class
mock_dialog_instance = Mock() mock_dialog_instance = Mock()
mock_dialog_instance.exec.return_value = 1 # Accepted 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) mock_dialog_class = Mock(return_value=mock_dialog_instance)
@ -279,7 +279,7 @@ class TestDialogMixinMocked:
mock_dialog_instance.get_values.assert_called_once() mock_dialog_instance.get_values.assert_called_once()
# Verify result # Verify result
assert result == {'key': 'value'} assert result == {"key": "value"}
def test_show_dialog_with_callback_flow(self): def test_show_dialog_with_callback_flow(self):
"""Test show_dialog method with callback""" """Test show_dialog method with callback"""
@ -293,7 +293,7 @@ class TestDialogMixinMocked:
# Mock dialog # Mock dialog
mock_dialog_instance = Mock() mock_dialog_instance = Mock()
mock_dialog_instance.exec.return_value = 1 # Accepted 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) 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") result = window.show_dialog(mock_dialog_class, on_accept=callback, param="value")
# Verify callback was called with dialog values # Verify callback was called with dialog values
callback.assert_called_once_with({'data': 'test'}) callback.assert_called_once_with({"data": "test"})
# Verify result # Verify result
assert result is True assert result is True
@ -346,7 +346,7 @@ class TestDialogActionDecoratorMocked:
# Mock dialog instance # Mock dialog instance
mock_dialog = Mock() mock_dialog = Mock()
mock_dialog.exec.return_value = QDialog.DialogCode.Accepted # Accepted 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 class
mock_dialog_cls = Mock(return_value=mock_dialog) mock_dialog_cls = Mock(return_value=mock_dialog)
@ -354,7 +354,7 @@ class TestDialogActionDecoratorMocked:
# Create decorated function # Create decorated function
@dialog_action(dialog_class=mock_dialog_cls, requires_pages=True) @dialog_action(dialog_class=mock_dialog_cls, requires_pages=True)
def test_function(self, values): def test_function(self, values):
return values['test'] return values["test"]
# Mock instance with required attributes # Mock instance with required attributes
instance = Mock() instance = Mock()
@ -375,7 +375,7 @@ class TestDialogActionDecoratorMocked:
mock_dialog.get_values.assert_called_once() mock_dialog.get_values.assert_called_once()
# Verify original function received values # Verify original function received values
assert result == 'data' assert result == "data"
def test_decorator_returns_early_when_no_pages(self): def test_decorator_returns_early_when_no_pages(self):
"""Test decorator returns early when pages required but not present""" """Test decorator returns early when pages required but not present"""
@ -407,7 +407,7 @@ class TestDialogActionDecoratorMocked:
mock_dialog = Mock() mock_dialog = Mock()
mock_dialog.exec.return_value = 1 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) mock_dialog_cls = Mock(return_value=mock_dialog)
@ -427,8 +427,8 @@ class TestDialogActionDecoratorMocked:
mock_dialog_cls.assert_called_once() mock_dialog_cls.assert_called_once()
# Verify result # Verify result
assert result == {'key': 'val'} assert result == {"key": "val"}
if __name__ == '__main__': if __name__ == "__main__":
pytest.main([__file__, '-v']) pytest.main([__file__, "-v"])

View File

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

View File

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

View File

@ -41,7 +41,7 @@ def sample_image(temp_dir):
from PIL import Image from PIL import Image
# Create a simple test 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") image_path = os.path.join(temp_dir, "test_image.jpg")
img.save(image_path) img.save(image_path)
return image_path return image_path
@ -59,7 +59,7 @@ class TestBasicSerialization:
assert success is True assert success is True
assert error is None assert error is None
assert os.path.exists(zip_path) 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): def test_save_adds_ppz_extension(self, sample_project, temp_dir):
"""Test that .ppz extension is added automatically""" """Test that .ppz extension is added automatically"""
@ -68,7 +68,7 @@ class TestBasicSerialization:
success, error = save_to_zip(sample_project, zip_path) success, error = save_to_zip(sample_project, zip_path)
assert success is True assert success is True
expected_path = zip_path + '.ppz' expected_path = zip_path + ".ppz"
assert os.path.exists(expected_path) assert os.path.exists(expected_path)
def test_load_empty_project(self, sample_project, temp_dir): def test_load_empty_project(self, sample_project, temp_dir):
@ -78,7 +78,6 @@ class TestBasicSerialization:
loaded_project = load_from_zip(zip_path) loaded_project = load_from_zip(zip_path)
assert loaded_project is not None assert loaded_project is not None
assert loaded_project.name == "Test Project" assert loaded_project.name == "Test Project"
assert loaded_project.page_size_mm == (210, 297) assert loaded_project.page_size_mm == (210, 297)
@ -137,33 +136,33 @@ class TestZipStructure:
zip_path = os.path.join(temp_dir, "test.ppz") zip_path = os.path.join(temp_dir, "test.ppz")
save_to_zip(sample_project, zip_path) save_to_zip(sample_project, zip_path)
with zipfile.ZipFile(zip_path, 'r') as zipf: with zipfile.ZipFile(zip_path, "r") as zipf:
assert 'project.json' in zipf.namelist() assert "project.json" in zipf.namelist()
def test_project_json_is_valid(self, sample_project, temp_dir): def test_project_json_is_valid(self, sample_project, temp_dir):
"""Test that project.json contains valid JSON""" """Test that project.json contains valid JSON"""
zip_path = os.path.join(temp_dir, "test.ppz") zip_path = os.path.join(temp_dir, "test.ppz")
save_to_zip(sample_project, zip_path) save_to_zip(sample_project, zip_path)
with zipfile.ZipFile(zip_path, 'r') as zipf: with zipfile.ZipFile(zip_path, "r") as zipf:
project_json = zipf.read('project.json').decode('utf-8') project_json = zipf.read("project.json").decode("utf-8")
data = json.loads(project_json) data = json.loads(project_json)
assert 'name' in data assert "name" in data
assert 'serialization_version' in data assert "serialization_version" in data
assert data['name'] == "Test Project" assert data["name"] == "Test Project"
def test_version_in_serialized_data(self, sample_project, temp_dir): def test_version_in_serialized_data(self, sample_project, temp_dir):
"""Test that version information is included""" """Test that version information is included"""
zip_path = os.path.join(temp_dir, "test.ppz") zip_path = os.path.join(temp_dir, "test.ppz")
save_to_zip(sample_project, zip_path) save_to_zip(sample_project, zip_path)
with zipfile.ZipFile(zip_path, 'r') as zipf: with zipfile.ZipFile(zip_path, "r") as zipf:
project_json = zipf.read('project.json').decode('utf-8') project_json = zipf.read("project.json").decode("utf-8")
data = json.loads(project_json) data = json.loads(project_json)
assert 'serialization_version' in data assert "serialization_version" in data
assert data['serialization_version'] == "3.0" assert data["serialization_version"] == "3.0"
class TestAssetManagement: class TestAssetManagement:
@ -205,10 +204,10 @@ class TestAssetManagement:
save_to_zip(sample_project, zip_path) save_to_zip(sample_project, zip_path)
# Check ZIP contents # Check ZIP contents
with zipfile.ZipFile(zip_path, 'r') as zipf: with zipfile.ZipFile(zip_path, "r") as zipf:
names = zipf.namelist() names = zipf.namelist()
# Should contain assets folder # 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 assert len(asset_files) > 0
def test_load_project_with_image(self, sample_project, sample_image, temp_dir): def test_load_project_with_image(self, sample_project, sample_image, temp_dir):
@ -348,10 +347,10 @@ class TestProjectInfo:
info = get_project_info(zip_path) info = get_project_info(zip_path)
assert info is not None assert info is not None
assert info['name'] == "Test Project" assert info["name"] == "Test Project"
assert info['page_count'] == 5 assert info["page_count"] == 5
assert info['version'] == "3.0" assert info["version"] == "3.0"
assert info['working_dpi'] == 300 assert info["working_dpi"] == 300
def test_get_info_invalid_zip(self, temp_dir): def test_get_info_invalid_zip(self, temp_dir):
"""Test getting info from invalid ZIP""" """Test getting info from invalid ZIP"""
@ -378,21 +377,17 @@ class TestEdgeCases:
"""Test loading a corrupted ZIP file""" """Test loading a corrupted ZIP file"""
# Create a fake corrupted file # Create a fake corrupted file
corrupted_path = os.path.join(temp_dir, "corrupted.ppz") 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") f.write("This is not a ZIP file")
try: try:
loaded_project = load_from_zip(corrupted_path) loaded_project = load_from_zip(corrupted_path)
assert False, "Should have raised an exception" assert False, "Should have raised an exception"
except Exception as error: except Exception as error:
assert error is not None assert error is not None
def test_load_zip_without_project_json(self, temp_dir): 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") zip_path = os.path.join(temp_dir, "no_json.ppz")
# Create ZIP without project.json # Create ZIP without project.json
with zipfile.ZipFile(zip_path, 'w') as zipf: with zipfile.ZipFile(zip_path, "w") as zipf:
zipf.writestr('dummy.txt', 'dummy content') zipf.writestr("dummy.txt", "dummy content")
try: try:
loaded_project = load_from_zip(zip_path) loaded_project = load_from_zip(zip_path)
@ -414,10 +409,7 @@ class TestEdgeCases:
"""Test saving/loading project with text elements""" """Test saving/loading project with text elements"""
# Create page with text # Create page with text
layout = PageLayout() layout = PageLayout()
text = TextBoxData( text = TextBoxData(text_content="Hello World", x=10, y=10, width=200, height=50)
text_content="Hello World",
x=10, y=10, width=200, height=50
)
layout.add_element(text) layout.add_element(text)
page = Page(layout=layout, page_number=1) page = Page(layout=layout, page_number=1)
sample_project.add_page(page) 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): def sample_image(self):
"""Create a sample test image""" """Create a sample test image"""
# Create a 400x200 test image (wider than tall) # 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 return img
def test_serialize_rotation_metadata(self): def test_serialize_rotation_metadata(self):
@ -47,7 +47,7 @@ class TestRotationSerialization:
"image_path": "test.jpg", "image_path": "test.jpg",
"crop_info": (0, 0, 1, 1), "crop_info": (0, 0, 1, 1),
"pil_rotation_90": 1, "pil_rotation_90": 1,
"image_dimensions": (400, 200) "image_dimensions": (400, 200),
} }
img_data = ImageData() img_data = ImageData()
@ -71,8 +71,10 @@ class TestRotationSerialization:
img_data._on_async_image_loaded(sample_image) img_data._on_async_image_loaded(sample_image)
# Verify dimensions are updated to rotated dimensions # Verify dimensions are updated to rotated dimensions
assert img_data.image_dimensions == (200, 400), \ assert img_data.image_dimensions == (
f"Expected rotated dimensions (200, 400), got {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_width == 200
assert img_data._img_height == 400 assert img_data._img_height == 400
@ -143,7 +145,7 @@ class TestRotationSerialization:
"image_path": "test.jpg", "image_path": "test.jpg",
"crop_info": (0, 0, 1, 1), "crop_info": (0, 0, 1, 1),
"pil_rotation_90": 0, # Not set in old format "pil_rotation_90": 0, # Not set in old format
"image_dimensions": (400, 200) "image_dimensions": (400, 200),
} }
img_data = ImageData() img_data = ImageData()
@ -182,7 +184,6 @@ class TestRotationSerialization:
img2._on_async_image_loaded(sample_image) img2._on_async_image_loaded(sample_image)
# Verify dimensions are STILL correct after reload # Verify dimensions are STILL correct after reload
assert img2.image_dimensions == (200, 400), \ assert img2.image_dimensions == (200, 400), "Dimensions should remain correct after reload"
"Dimensions should remain correct after reload"
assert img2._img_width == 200 assert img2._img_width == 200
assert img2._img_height == 400 assert img2._img_height == 400

View File

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

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