diff --git a/pyPhotoAlbum/alignment.py b/pyPhotoAlbum/alignment.py index 9e1307d..1abc5a7 100644 --- a/pyPhotoAlbum/alignment.py +++ b/pyPhotoAlbum/alignment.py @@ -65,14 +65,10 @@ class ElementMaximizer: # Calculate distances between rectangles horizontal_gap = max( - other_x - (x + w), # Other is to the right - x - (other_x + other_w) # Other is to the left + other_x - (x + w), x - (other_x + other_w) # Other is to the right # Other is to the left ) - vertical_gap = max( - other_y - (y + h), # Other is below - y - (other_y + other_h) # Other is above - ) + vertical_gap = max(other_y - (y + h), y - (other_y + other_h)) # Other is below # Other is above # If rectangles overlap or are too close in both dimensions if horizontal_gap < self.min_gap and vertical_gap < self.min_gap: @@ -80,8 +76,14 @@ class ElementMaximizer: return False - def find_max_scale(self, elem_idx: int, current_scale: float, max_search_scale: float = 3.0, - tolerance: float = 0.001, max_iterations: int = 20) -> float: + def find_max_scale( + self, + elem_idx: int, + current_scale: float, + max_search_scale: float = 3.0, + tolerance: float = 0.001, + max_iterations: int = 20, + ) -> float: """ Use binary search to find the maximum scale factor for an element. @@ -171,8 +173,10 @@ class ElementMaximizer: ow, oh = other.size # Check if rectangles overlap (with min_gap consideration) - if (abs((x + w/2) - (ox + ow/2)) < (w + ow)/2 + self.min_gap and - abs((y + h/2) - (oy + oh/2)) < (h + oh)/2 + self.min_gap): + if ( + abs((x + w / 2) - (ox + ow / 2)) < (w + ow) / 2 + self.min_gap + and abs((y + h / 2) - (oy + oh / 2)) < (h + oh) / 2 + self.min_gap + ): return True return False @@ -233,7 +237,9 @@ class ElementMaximizer: self.center_element_horizontally(elem) self.center_element_vertically(elem) - def maximize(self, max_iterations: int = 100, growth_rate: float = 0.05) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: + def maximize( + self, max_iterations: int = 100, growth_rate: float = 0.05 + ) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: """ Execute the maximization algorithm. @@ -365,7 +371,9 @@ class AlignmentManager: return changes @staticmethod - def align_horizontal_center(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: + def align_horizontal_center( + elements: List[BaseLayoutElement], + ) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: """ Align all elements to horizontal center. @@ -413,7 +421,9 @@ class AlignmentManager: return changes @staticmethod - def make_same_size(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: + def make_same_size( + elements: List[BaseLayoutElement], + ) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: """ Make all elements the same size as the first element. @@ -435,7 +445,9 @@ class AlignmentManager: return changes @staticmethod - def make_same_width(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: + def make_same_width( + elements: List[BaseLayoutElement], + ) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: """ Make all elements the same width as the first element. @@ -457,7 +469,9 @@ class AlignmentManager: return changes @staticmethod - def make_same_height(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: + def make_same_height( + elements: List[BaseLayoutElement], + ) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: """ Make all elements the same height as the first element. @@ -479,7 +493,9 @@ class AlignmentManager: return changes @staticmethod - def distribute_horizontally(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: + def distribute_horizontally( + elements: List[BaseLayoutElement], + ) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: """ Distribute elements evenly across horizontal span. @@ -613,7 +629,9 @@ class AlignmentManager: return changes @staticmethod - def fit_to_page_width(element: BaseLayoutElement, page_width: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: + def fit_to_page_width( + element: BaseLayoutElement, page_width: float + ) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: """ Resize element to fit page width while maintaining aspect ratio. @@ -638,7 +656,9 @@ class AlignmentManager: return (element, old_pos, old_size) @staticmethod - def fit_to_page_height(element: BaseLayoutElement, page_height: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: + def fit_to_page_height( + element: BaseLayoutElement, page_height: float + ) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: """ Resize element to fit page height while maintaining aspect ratio. @@ -663,7 +683,9 @@ class AlignmentManager: return (element, old_pos, old_size) @staticmethod - def fit_to_page(element: BaseLayoutElement, page_width: float, page_height: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: + def fit_to_page( + element: BaseLayoutElement, page_width: float, page_height: float + ) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: """ Resize element to fit within page dimensions while maintaining aspect ratio. @@ -702,7 +724,7 @@ class AlignmentManager: page_size: Tuple[float, float], min_gap: float = 2.0, max_iterations: int = 100, - growth_rate: float = 0.05 + growth_rate: float = 0.05, ) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: """ Maximize element sizes using a crystal growth algorithm. @@ -729,7 +751,7 @@ class AlignmentManager: element: BaseLayoutElement, page_size: Tuple[float, float], other_elements: List[BaseLayoutElement], - min_gap: float = 10.0 + min_gap: float = 10.0, ) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: """ Expand a single element until it is min_gap away from page edges or other elements. diff --git a/pyPhotoAlbum/asset_heal_dialog.py b/pyPhotoAlbum/asset_heal_dialog.py index cdeb506..40f79de 100644 --- a/pyPhotoAlbum/asset_heal_dialog.py +++ b/pyPhotoAlbum/asset_heal_dialog.py @@ -6,9 +6,16 @@ import os import shutil from typing import List, Dict, Set from PyQt6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, - QListWidget, QListWidgetItem, QFileDialog, QGroupBox, - QMessageBox + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QListWidget, + QListWidgetItem, + QFileDialog, + QGroupBox, + QMessageBox, ) from PyQt6.QtCore import Qt @@ -124,10 +131,7 @@ class AssetHealDialog(QDialog): def _add_search_path(self): """Add a search path""" directory = QFileDialog.getExistingDirectory( - self, - "Select Search Path for Assets", - "", - QFileDialog.Option.ShowDirsOnly + self, "Select Search Path for Assets", "", QFileDialog.Option.ShowDirsOnly ) if directory: @@ -203,7 +207,7 @@ class AssetHealDialog(QDialog): # Check if the found file needs to be imported # (i.e., it's not already in the assets folder) needs_import = True - if not os.path.isabs(asset_path) and asset_path.startswith('assets/'): + if not os.path.isabs(asset_path) and asset_path.startswith("assets/"): # It's already a relative assets path, just missing from disk # Copy it to the correct location dest_path = os.path.join(self.project.folder_path, asset_path) diff --git a/pyPhotoAlbum/asset_manager.py b/pyPhotoAlbum/asset_manager.py index 77687bd..b3e1868 100644 --- a/pyPhotoAlbum/asset_manager.py +++ b/pyPhotoAlbum/asset_manager.py @@ -10,48 +10,48 @@ from pathlib import Path class AssetManager: """Manages project assets with automatic reference counting and cleanup""" - + def __init__(self, project_folder: str): """ Initialize AssetManager. - + Args: project_folder: Root folder for the project """ self.project_folder = project_folder self.assets_folder = os.path.join(project_folder, "assets") self.reference_counts: Dict[str, int] = {} # {relative_path: count} - + # Create assets folder if it doesn't exist os.makedirs(self.assets_folder, exist_ok=True) - + def import_asset(self, source_path: str) -> str: """ Import an asset into the project by copying it to the assets folder. - + Args: source_path: Path to the source file - + Returns: Relative path to the imported asset (e.g., "assets/photo_001.jpg") """ if not os.path.exists(source_path): raise FileNotFoundError(f"Source file not found: {source_path}") - + # Get filename and extension filename = os.path.basename(source_path) name, ext = os.path.splitext(filename) - + # Find a unique filename if there's a collision counter = 1 dest_filename = filename dest_path = os.path.join(self.assets_folder, dest_filename) - + while os.path.exists(dest_path): dest_filename = f"{name}_{counter:03d}{ext}" dest_path = os.path.join(self.assets_folder, dest_filename) counter += 1 - + # Copy the file shutil.copy2(source_path, dest_path) @@ -65,17 +65,17 @@ class AssetManager: # Return relative path for storage in elements return relative_path - + def acquire_reference(self, asset_path: str): """ Increment the reference count for an asset. - + Args: asset_path: Relative path to the asset """ if not asset_path: return - + if asset_path in self.reference_counts: self.reference_counts[asset_path] += 1 print(f"AssetManager: Acquired reference to {asset_path} (refs={self.reference_counts[asset_path]})") @@ -87,25 +87,25 @@ class AssetManager: print(f"AssetManager: Acquired reference to existing asset {asset_path} (refs=1)") else: print(f"AssetManager: Warning - asset not found: {asset_path}") - + def release_reference(self, asset_path: str): """ Decrement the reference count for an asset. If count reaches zero, delete the asset file. - + Args: asset_path: Relative path to the asset """ if not asset_path: return - + if asset_path not in self.reference_counts: print(f"AssetManager: Warning - attempting to release unknown asset: {asset_path}") return - + self.reference_counts[asset_path] -= 1 print(f"AssetManager: Released reference to {asset_path} (refs={self.reference_counts[asset_path]})") - + if self.reference_counts[asset_path] <= 0: # No more references - safe to delete full_path = os.path.join(self.project_folder, asset_path) @@ -116,37 +116,35 @@ class AssetManager: del self.reference_counts[asset_path] except Exception as e: print(f"AssetManager: Error deleting asset {asset_path}: {e}") - + def get_absolute_path(self, relative_path: str) -> str: """ Convert a relative asset path to an absolute path. - + Args: relative_path: Relative path from project folder - + Returns: Absolute path to the asset """ return os.path.join(self.project_folder, relative_path) - + def get_reference_count(self, asset_path: str) -> int: """ Get the current reference count for an asset. - + Args: asset_path: Relative path to the asset - + Returns: Reference count (0 if not tracked) """ return self.reference_counts.get(asset_path, 0) - + def serialize(self) -> Dict: """Serialize asset manager state""" - return { - "reference_counts": self.reference_counts - } - + return {"reference_counts": self.reference_counts} + def deserialize(self, data: Dict): """Deserialize asset manager state""" self.reference_counts = data.get("reference_counts", {}) diff --git a/pyPhotoAlbum/async_backend.py b/pyPhotoAlbum/async_backend.py index 898e55f..ca0f46b 100644 --- a/pyPhotoAlbum/async_backend.py +++ b/pyPhotoAlbum/async_backend.py @@ -28,16 +28,14 @@ logger = logging.getLogger(__name__) class LoadPriority(Enum): """Priority levels for load requests.""" - LOW = 0 # Offscreen, not visible - NORMAL = 1 # Potentially visible soon - HIGH = 2 # Visible on screen - URGENT = 3 # User is actively interacting with + + LOW = 0 # Offscreen, not visible + NORMAL = 1 # Potentially visible soon + HIGH = 2 # Visible on screen + URGENT = 3 # User is actively interacting with -def get_image_dimensions( - image_path: str, - max_size: Optional[int] = None -) -> Optional[Tuple[int, int]]: +def get_image_dimensions(image_path: str, max_size: Optional[int] = None) -> Optional[Tuple[int, int]]: """ Extract image dimensions without loading the full image. @@ -78,6 +76,7 @@ def get_image_dimensions( @dataclass(order=True) class LoadRequest: """Request to load and process an image.""" + priority: LoadPriority = field(compare=True) request_id: int = field(compare=True) # Tie-breaker for same priority path: Path = field(compare=False) @@ -111,7 +110,7 @@ class ImageCache: """Estimate memory size of PIL image in bytes.""" # PIL images are typically width * height * bytes_per_pixel # RGBA = 4 bytes, RGB = 3 bytes, L = 1 byte - mode_sizes = {'RGBA': 4, 'RGB': 3, 'L': 1, 'LA': 2} + mode_sizes = {"RGBA": 4, "RGB": 3, "L": 1, "LA": 2} bytes_per_pixel = mode_sizes.get(img.mode, 4) return img.width * img.height * bytes_per_pixel @@ -164,8 +163,7 @@ class ImageCache: self.current_memory_bytes -= old_size # Evict LRU items if needed - while (self.current_memory_bytes + img_size > self.max_memory_bytes - and len(self._cache) > 0): + while self.current_memory_bytes + img_size > self.max_memory_bytes and len(self._cache) > 0: evicted_key, (evicted_img, evicted_size) = self._cache.popitem(last=False) self.current_memory_bytes -= evicted_size logger.debug(f"Cache EVICT: {evicted_key} ({evicted_size / 1024 / 1024:.1f}MB)") @@ -174,10 +172,12 @@ class ImageCache: self._cache[key] = (img.copy(), img_size) self.current_memory_bytes += img_size - logger.debug(f"Cache PUT: {key} ({img_size / 1024 / 1024:.1f}MB) " - f"[Total: {self.current_memory_bytes / 1024 / 1024:.1f}MB / " - f"{self.max_memory_bytes / 1024 / 1024:.1f}MB, " - f"Items: {len(self._cache)}]") + logger.debug( + f"Cache PUT: {key} ({img_size / 1024 / 1024:.1f}MB) " + f"[Total: {self.current_memory_bytes / 1024 / 1024:.1f}MB / " + f"{self.max_memory_bytes / 1024 / 1024:.1f}MB, " + f"Items: {len(self._cache)}]" + ) def clear(self): """Clear entire cache.""" @@ -190,10 +190,10 @@ class ImageCache: """Get cache statistics.""" with self._lock: return { - 'items': len(self._cache), - 'memory_mb': self.current_memory_bytes / 1024 / 1024, - 'max_memory_mb': self.max_memory_bytes / 1024 / 1024, - 'utilization': (self.current_memory_bytes / self.max_memory_bytes) * 100 + "items": len(self._cache), + "memory_mb": self.current_memory_bytes / 1024 / 1024, + "max_memory_mb": self.max_memory_bytes / 1024 / 1024, + "utilization": (self.current_memory_bytes / self.max_memory_bytes) * 100, } @@ -213,7 +213,7 @@ class AsyncImageLoader(QObject): # Signals for Qt integration image_loaded = pyqtSignal(object, object, object) # (path, image, user_data) - load_failed = pyqtSignal(object, str, object) # (path, error_msg, user_data) + load_failed = pyqtSignal(object, str, object) # (path, error_msg, user_data) def __init__(self, cache: Optional[ImageCache] = None, max_workers: int = 4): """ @@ -227,8 +227,7 @@ class AsyncImageLoader(QObject): self.cache = cache or ImageCache() self.max_workers = max_workers - self.executor = ThreadPoolExecutor(max_workers=max_workers, - thread_name_prefix="ImageLoader") + self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="ImageLoader") # Priority queue and tracking self._queue: asyncio.PriorityQueue = None # Created when event loop starts @@ -251,9 +250,9 @@ class AsyncImageLoader(QObject): return self._shutdown = False - self._loop_thread = threading.Thread(target=self._run_event_loop, - daemon=True, - name="AsyncImageLoader-EventLoop") + self._loop_thread = threading.Thread( + target=self._run_event_loop, daemon=True, name="AsyncImageLoader-EventLoop" + ) self._loop_thread.start() logger.info("AsyncImageLoader event loop started") @@ -354,12 +353,7 @@ class AsyncImageLoader(QObject): # Load in thread pool (I/O bound) loop = asyncio.get_event_loop() - img = await loop.run_in_executor( - self.executor, - self._load_and_process_image, - path, - target_size - ) + img = await loop.run_in_executor(self.executor, self._load_and_process_image, path, target_size) # Cache result self.cache.put(path, img, target_size) @@ -411,11 +405,13 @@ class AsyncImageLoader(QObject): """Emit load_failed signal (thread-safe).""" self.load_failed.emit(path, error_msg, user_data) - def request_load(self, - path: Path, - priority: LoadPriority = LoadPriority.NORMAL, - target_size: Optional[Tuple[int, int]] = None, - user_data: Any = None) -> bool: + def request_load( + self, + path: Path, + priority: LoadPriority = LoadPriority.NORMAL, + target_size: Optional[Tuple[int, int]] = None, + user_data: Any = None, + ) -> bool: """ Request image load with specified priority. @@ -446,7 +442,7 @@ class AsyncImageLoader(QObject): request_id=self._next_request_id, path=path, target_size=target_size, - user_data=user_data + user_data=user_data, ) self._next_request_id += 1 @@ -454,10 +450,7 @@ class AsyncImageLoader(QObject): self._pending_requests[path] = request # Submit to queue (thread-safe) - asyncio.run_coroutine_threadsafe( - self._queue.put(request), - self._loop - ) + asyncio.run_coroutine_threadsafe(self._queue.put(request), self._loop) logger.debug(f"Queued load: {path} (priority: {priority.name})") return True @@ -494,9 +487,9 @@ class AsyncImageLoader(QObject): """Get loader statistics.""" with self._lock: return { - 'pending': len(self._pending_requests), - 'active': len(self._active_tasks), - 'cache': self.cache.get_stats() + "pending": len(self._pending_requests), + "active": len(self._active_tasks), + "cache": self.cache.get_stats(), } @@ -517,8 +510,8 @@ class AsyncPDFGenerator(QObject): # Signals for Qt integration progress_updated = pyqtSignal(int, int, str) # (current, total, message) - export_complete = pyqtSignal(bool, list) # (success, warnings) - export_failed = pyqtSignal(str) # (error_message) + export_complete = pyqtSignal(bool, list) # (success, warnings) + export_failed = pyqtSignal(str) # (error_message) def __init__(self, image_cache: Optional[ImageCache] = None, max_workers: int = 2): """ @@ -532,8 +525,7 @@ class AsyncPDFGenerator(QObject): self.image_cache = image_cache or ImageCache() self.max_workers = max_workers - self.executor = ThreadPoolExecutor(max_workers=max_workers, - thread_name_prefix="PDFGenerator") + self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="PDFGenerator") # Export state self._current_export: Optional[asyncio.Task] = None @@ -554,9 +546,9 @@ class AsyncPDFGenerator(QObject): return self._shutdown = False - self._loop_thread = threading.Thread(target=self._run_event_loop, - daemon=True, - name="AsyncPDFGenerator-EventLoop") + self._loop_thread = threading.Thread( + target=self._run_event_loop, daemon=True, name="AsyncPDFGenerator-EventLoop" + ) self._loop_thread.start() logger.info("AsyncPDFGenerator event loop started") @@ -621,8 +613,7 @@ class AsyncPDFGenerator(QObject): # Submit export task self._current_export = asyncio.run_coroutine_threadsafe( - self._export_pdf_async(project, output_path, export_dpi), - self._loop + self._export_pdf_async(project, output_path, export_dpi), self._loop ) logger.info(f"PDF export started: {output_path}") @@ -662,11 +653,7 @@ class AsyncPDFGenerator(QObject): # Run export in thread pool loop = asyncio.get_event_loop() success, warnings = await loop.run_in_executor( - self.executor, - self._export_with_cache, - exporter, - output_path, - progress_callback + self.executor, self._export_with_cache, exporter, output_path, progress_callback ) # Emit completion signal @@ -731,13 +718,9 @@ class AsyncPDFGenerator(QObject): def is_exporting(self) -> bool: """Check if export is currently in progress.""" with self._lock: - return (self._current_export is not None - and not self._current_export.done()) + return self._current_export is not None and not self._current_export.done() def get_stats(self) -> Dict[str, Any]: """Get generator statistics.""" with self._lock: - return { - 'exporting': self.is_exporting(), - 'cache': self.image_cache.get_stats() - } + return {"exporting": self.is_exporting(), "cache": self.image_cache.get_stats()} diff --git a/pyPhotoAlbum/async_project_loader.py b/pyPhotoAlbum/async_project_loader.py index 8ee9d2f..c21342c 100644 --- a/pyPhotoAlbum/async_project_loader.py +++ b/pyPhotoAlbum/async_project_loader.py @@ -18,7 +18,7 @@ from pyPhotoAlbum.version_manager import ( CURRENT_DATA_VERSION, check_version_compatibility, VersionCompatibility, - DataMigration + DataMigration, ) @@ -78,7 +78,7 @@ class AsyncProjectLoader(QThread): self.progress_updated.emit(10, 100, "Extracting project files...") # Extract ZIP contents with progress - with zipfile.ZipFile(self.zip_path, 'r') as zipf: + with zipfile.ZipFile(self.zip_path, "r") as zipf: file_list = zipf.namelist() total_files = len(file_list) @@ -91,10 +91,7 @@ class AsyncProjectLoader(QThread): # Update progress every 10 files or on last file if i % 10 == 0 or i == total_files - 1: progress = 10 + int((i / total_files) * 30) # 10-40% - self.progress_updated.emit( - progress, 100, - f"Extracting files... ({i + 1}/{total_files})" - ) + self.progress_updated.emit(progress, 100, f"Extracting files... ({i + 1}/{total_files})") if self._cancelled: return @@ -103,12 +100,12 @@ class AsyncProjectLoader(QThread): self.progress_updated.emit(45, 100, "Loading project data...") # Load project.json - project_json_path = os.path.join(extract_to, 'project.json') + project_json_path = os.path.join(extract_to, "project.json") if not os.path.exists(project_json_path): self.load_failed.emit("Invalid project file: project.json not found") return - with open(project_json_path, 'r') as f: + with open(project_json_path, "r") as f: project_data = json.load(f) if self._cancelled: @@ -118,7 +115,7 @@ class AsyncProjectLoader(QThread): self.progress_updated.emit(55, 100, "Checking version compatibility...") # Check version compatibility - file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0')) + file_version = project_data.get("data_version", project_data.get("serialization_version", "1.0")) is_compatible, error_msg = check_version_compatibility(file_version, self.zip_path) if not is_compatible: @@ -141,7 +138,7 @@ class AsyncProjectLoader(QThread): self.progress_updated.emit(70, 100, "Creating project...") # Create new project - project_name = project_data.get('name', 'Untitled Project') + project_name = project_data.get("name", "Untitled Project") project = Project(name=project_name, folder_path=extract_to) # Deserialize project data @@ -197,14 +194,14 @@ class AsyncProjectLoader(QThread): original_path = element.image_path # Skip if already a simple relative path - if not os.path.isabs(original_path) and not original_path.startswith('./projects/'): + if not os.path.isabs(original_path) and not original_path.startswith("./projects/"): continue # Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg" - if '/assets/' in original_path: - parts = original_path.split('/assets/') + if "/assets/" in original_path: + parts = original_path.split("/assets/") if len(parts) == 2: - new_path = os.path.join('assets', parts[1]) + new_path = os.path.join("assets", parts[1]) element.image_path = new_path normalized_count += 1 continue @@ -222,9 +219,9 @@ class AsyncProjectLoader(QThread): print(f"Normalized {normalized_count} asset paths") -def load_from_zip_async(zip_path: str, extract_to: Optional[str] = None, - progress_callback=None, complete_callback=None, - error_callback=None) -> AsyncProjectLoader: +def load_from_zip_async( + zip_path: str, extract_to: Optional[str] = None, progress_callback=None, complete_callback=None, error_callback=None +) -> AsyncProjectLoader: """ Load a project from a ZIP file asynchronously. diff --git a/pyPhotoAlbum/autosave_manager.py b/pyPhotoAlbum/autosave_manager.py index b964914..827779b 100644 --- a/pyPhotoAlbum/autosave_manager.py +++ b/pyPhotoAlbum/autosave_manager.py @@ -86,11 +86,11 @@ class AutosaveManager: "project_name": project.name, "timestamp": datetime.now().isoformat(), "checkpoint_path": str(checkpoint_path), - "original_path": getattr(project, 'file_path', None), + "original_path": getattr(project, "file_path", None), } - metadata_path = checkpoint_path.with_suffix('.json') - with open(metadata_path, 'w') as f: + metadata_path = checkpoint_path.with_suffix(".json") + with open(metadata_path, "w") as f: json.dump(metadata, f, indent=2) def list_checkpoints(self, project_name: Optional[str] = None) -> List[Tuple[Path, dict]]: @@ -106,23 +106,23 @@ class AutosaveManager: checkpoints = [] for checkpoint_file in self.CHECKPOINT_DIR.glob(f"{self.CHECKPOINT_PREFIX}*{self.CHECKPOINT_EXTENSION}"): - metadata_file = checkpoint_file.with_suffix('.json') + metadata_file = checkpoint_file.with_suffix(".json") # Try to load metadata metadata = {} if metadata_file.exists(): try: - with open(metadata_file, 'r') as f: + with open(metadata_file, "r") as f: metadata = json.load(f) except: pass # Filter by project name if specified - if project_name is None or metadata.get('project_name') == project_name: + if project_name is None or metadata.get("project_name") == project_name: checkpoints.append((checkpoint_file, metadata)) # Sort by timestamp (newest first) - checkpoints.sort(key=lambda x: x[1].get('timestamp', ''), reverse=True) + checkpoints.sort(key=lambda x: x[1].get("timestamp", ""), reverse=True) return checkpoints def load_checkpoint(self, checkpoint_path: Path): @@ -157,7 +157,7 @@ class AutosaveManager: checkpoint_path.unlink() # Delete metadata file - metadata_path = checkpoint_path.with_suffix('.json') + metadata_path = checkpoint_path.with_suffix(".json") if metadata_path.exists(): metadata_path.unlink() @@ -190,7 +190,7 @@ class AutosaveManager: # Group checkpoints by project for checkpoint_path, metadata in self.list_checkpoints(): - project_name = metadata.get('project_name', 'unknown') + project_name = metadata.get("project_name", "unknown") if project_name not in checkpoints_by_project: checkpoints_by_project[project_name] = [] checkpoints_by_project[project_name].append((checkpoint_path, metadata)) @@ -198,11 +198,11 @@ class AutosaveManager: # Clean up each project's checkpoints for project_name, checkpoints in checkpoints_by_project.items(): # Sort by timestamp (newest first) - checkpoints.sort(key=lambda x: x[1].get('timestamp', ''), reverse=True) + checkpoints.sort(key=lambda x: x[1].get("timestamp", ""), reverse=True) for idx, (checkpoint_path, metadata) in enumerate(checkpoints): # Delete if too old - timestamp_str = metadata.get('timestamp') + timestamp_str = metadata.get("timestamp") if timestamp_str: try: timestamp = datetime.fromisoformat(timestamp_str) diff --git a/pyPhotoAlbum/commands.py b/pyPhotoAlbum/commands.py index e6c5b0f..9079e62 100644 --- a/pyPhotoAlbum/commands.py +++ b/pyPhotoAlbum/commands.py @@ -11,11 +11,11 @@ from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, T def _normalize_asset_path(image_path: str, asset_manager) -> str: """ Convert absolute path to relative for asset manager. - + Args: image_path: Image path (absolute or relative) asset_manager: AssetManager instance - + Returns: Relative path suitable for asset manager """ @@ -29,79 +29,75 @@ def _normalize_asset_path(image_path: str, asset_manager) -> str: class Command(ABC): """Abstract base class for all commands""" - + @abstractmethod def execute(self): """Execute the command""" pass - + @abstractmethod def undo(self): """Undo the command""" pass - + @abstractmethod def redo(self): """Redo the command (default implementation calls execute)""" self.execute() - + @abstractmethod def serialize(self) -> Dict[str, Any]: """Serialize command to dictionary for saving""" pass - + @staticmethod @abstractmethod - def deserialize(data: Dict[str, Any], project) -> 'Command': + def deserialize(data: Dict[str, Any], project) -> "Command": """Deserialize command from dictionary""" pass class AddElementCommand(Command): """Command for adding an element to a page""" - + def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None): self.page_layout = page_layout self.element = element self.executed = False self.asset_manager = asset_manager - + # Acquire reference to asset when command is created if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path: rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager) self.asset_manager.acquire_reference(rel_path) - + def execute(self): """Add the element to the page""" if not self.executed: self.page_layout.add_element(self.element) self.executed = True - + def undo(self): """Remove the element from the page""" if self.executed: self.page_layout.remove_element(self.element) self.executed = False - + def redo(self): """Re-add the element""" self.execute() - + def serialize(self) -> Dict[str, Any]: """Serialize to dictionary""" - return { - "type": "add_element", - "element": self.element.serialize(), - "executed": self.executed - } - + return {"type": "add_element", "element": self.element.serialize(), "executed": self.executed} + @staticmethod - def deserialize(data: Dict[str, Any], project) -> 'AddElementCommand': + def deserialize(data: Dict[str, Any], project) -> "AddElementCommand": """Deserialize from dictionary""" # Reconstruct element from serialized data elem_data = data["element"] elem_type = elem_data.get("type") - + if elem_type == "image": element = ImageData() elif elem_type == "placeholder": @@ -110,9 +106,9 @@ class AddElementCommand(Command): element = TextBoxData() else: raise ValueError(f"Unknown element type: {elem_type}") - + element.deserialize(elem_data) - + # Note: We need to find the correct page_layout # This will be handled by the CommandHistory deserializer cmd = AddElementCommand(None, element) @@ -122,48 +118,44 @@ class AddElementCommand(Command): class DeleteElementCommand(Command): """Command for deleting an element from a page""" - + def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None): self.page_layout = page_layout self.element = element self.executed = False self.asset_manager = asset_manager - + # Acquire reference to asset to keep it alive while in undo history if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path: rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager) self.asset_manager.acquire_reference(rel_path) - + def execute(self): """Remove the element from the page""" if not self.executed: self.page_layout.remove_element(self.element) self.executed = True - + def undo(self): """Re-add the element to the page""" if self.executed: self.page_layout.add_element(self.element) self.executed = False - + def redo(self): """Re-remove the element""" self.execute() - + def serialize(self) -> Dict[str, Any]: """Serialize to dictionary""" - return { - "type": "delete_element", - "element": self.element.serialize(), - "executed": self.executed - } - + return {"type": "delete_element", "element": self.element.serialize(), "executed": self.executed} + @staticmethod - def deserialize(data: Dict[str, Any], project) -> 'DeleteElementCommand': + def deserialize(data: Dict[str, Any], project) -> "DeleteElementCommand": """Deserialize from dictionary""" elem_data = data["element"] elem_type = elem_data.get("type") - + if elem_type == "image": element = ImageData() elif elem_type == "placeholder": @@ -172,9 +164,9 @@ class DeleteElementCommand(Command): element = TextBoxData() else: raise ValueError(f"Unknown element type: {elem_type}") - + element.deserialize(elem_data) - + cmd = DeleteElementCommand(None, element) cmd.executed = data.get("executed", False) return cmd @@ -182,39 +174,39 @@ class DeleteElementCommand(Command): class MoveElementCommand(Command): """Command for moving an element""" - + def __init__(self, element: BaseLayoutElement, old_position: tuple, new_position: tuple): self.element = element self.old_position = old_position self.new_position = new_position - + def execute(self): """Move element to new position""" self.element.position = self.new_position - + def undo(self): """Move element back to old position""" self.element.position = self.old_position - + def redo(self): """Move element to new position again""" self.execute() - + def serialize(self) -> Dict[str, Any]: """Serialize to dictionary""" return { "type": "move_element", "element": self.element.serialize(), "old_position": self.old_position, - "new_position": self.new_position + "new_position": self.new_position, } - + @staticmethod - def deserialize(data: Dict[str, Any], project) -> 'MoveElementCommand': + def deserialize(data: Dict[str, Any], project) -> "MoveElementCommand": """Deserialize from dictionary""" elem_data = data["element"] elem_type = elem_data.get("type") - + if elem_type == "image": element = ImageData() elif elem_type == "placeholder": @@ -223,41 +215,38 @@ class MoveElementCommand(Command): element = TextBoxData() else: raise ValueError(f"Unknown element type: {elem_type}") - + element.deserialize(elem_data) - - return MoveElementCommand( - element, - tuple(data["old_position"]), - tuple(data["new_position"]) - ) + + return MoveElementCommand(element, tuple(data["old_position"]), tuple(data["new_position"])) class ResizeElementCommand(Command): """Command for resizing an element""" - - def __init__(self, element: BaseLayoutElement, old_position: tuple, old_size: tuple, - new_position: tuple, new_size: tuple): + + def __init__( + self, element: BaseLayoutElement, old_position: tuple, old_size: tuple, new_position: tuple, new_size: tuple + ): self.element = element self.old_position = old_position self.old_size = old_size self.new_position = new_position self.new_size = new_size - + def execute(self): """Resize element to new size""" self.element.position = self.new_position self.element.size = self.new_size - + def undo(self): """Resize element back to old size""" self.element.position = self.old_position self.element.size = self.old_size - + def redo(self): """Resize element to new size again""" self.execute() - + def serialize(self) -> Dict[str, Any]: """Serialize to dictionary""" return { @@ -266,15 +255,15 @@ class ResizeElementCommand(Command): "old_position": self.old_position, "old_size": self.old_size, "new_position": self.new_position, - "new_size": self.new_size + "new_size": self.new_size, } - + @staticmethod - def deserialize(data: Dict[str, Any], project) -> 'ResizeElementCommand': + def deserialize(data: Dict[str, Any], project) -> "ResizeElementCommand": """Deserialize from dictionary""" elem_data = data["element"] elem_type = elem_data.get("type") - + if elem_type == "image": element = ImageData() elif elem_type == "placeholder": @@ -283,15 +272,15 @@ class ResizeElementCommand(Command): element = TextBoxData() else: raise ValueError(f"Unknown element type: {elem_type}") - + element.deserialize(elem_data) - + return ResizeElementCommand( element, tuple(data["old_position"]), tuple(data["old_size"]), tuple(data["new_position"]), - tuple(data["new_size"]) + tuple(data["new_size"]), ) @@ -308,7 +297,7 @@ class RotateElementCommand(Command): self.old_size = element.size # For ImageData, store the old PIL rotation state - if hasattr(element, 'pil_rotation_90'): + if hasattr(element, "pil_rotation_90"): self.old_pil_rotation = element.pil_rotation_90 else: self.old_pil_rotation = None @@ -344,9 +333,9 @@ class RotateElementCommand(Command): self.element.position = (center_x - h / 2, center_y - w / 2) # Clear the texture so it will be reloaded with the new rotation - if hasattr(self.element, '_texture_id'): + if hasattr(self.element, "_texture_id"): del self.element._texture_id - if hasattr(self.element, '_async_load_requested'): + if hasattr(self.element, "_async_load_requested"): self.element._async_load_requested = False # Keep visual rotation at 0 @@ -376,29 +365,29 @@ class RotateElementCommand(Command): # For ImageData, restore PIL rotation and clear texture if isinstance(self.element, ImageData) and self.old_pil_rotation is not None: self.element.pil_rotation_90 = self.old_pil_rotation - if hasattr(self.element, '_texture_id'): + if hasattr(self.element, "_texture_id"): self.element._texture_id = None self.element._async_load_requested = False def redo(self): """Rotate element to new angle again""" self.execute() - + def serialize(self) -> Dict[str, Any]: """Serialize to dictionary""" return { "type": "rotate_element", "element": self.element.serialize(), "old_rotation": self.old_rotation, - "new_rotation": self.new_rotation + "new_rotation": self.new_rotation, } - + @staticmethod - def deserialize(data: Dict[str, Any], project) -> 'RotateElementCommand': + def deserialize(data: Dict[str, Any], project) -> "RotateElementCommand": """Deserialize from dictionary""" elem_data = data["element"] elem_type = elem_data.get("type") - + if elem_type == "image": element = ImageData() elif elem_type == "placeholder": @@ -407,78 +396,70 @@ class RotateElementCommand(Command): element = TextBoxData() else: raise ValueError(f"Unknown element type: {elem_type}") - + element.deserialize(elem_data) - - return RotateElementCommand( - element, - data["old_rotation"], - data["new_rotation"] - ) + + return RotateElementCommand(element, data["old_rotation"], data["new_rotation"]) class AdjustImageCropCommand(Command): """Command for adjusting image crop/pan within frame""" - + def __init__(self, element: ImageData, old_crop_info: tuple, new_crop_info: tuple): self.element = element self.old_crop_info = old_crop_info self.new_crop_info = new_crop_info - + def execute(self): """Apply new crop info""" self.element.crop_info = self.new_crop_info - + def undo(self): """Restore old crop info""" self.element.crop_info = self.old_crop_info - + def redo(self): """Apply new crop info again""" self.execute() - + def serialize(self) -> Dict[str, Any]: """Serialize to dictionary""" return { "type": "adjust_image_crop", "element": self.element.serialize(), "old_crop_info": self.old_crop_info, - "new_crop_info": self.new_crop_info + "new_crop_info": self.new_crop_info, } - + @staticmethod - def deserialize(data: Dict[str, Any], project) -> 'AdjustImageCropCommand': + def deserialize(data: Dict[str, Any], project) -> "AdjustImageCropCommand": """Deserialize from dictionary""" elem_data = data["element"] element = ImageData() element.deserialize(elem_data) - - return AdjustImageCropCommand( - element, - tuple(data["old_crop_info"]), - tuple(data["new_crop_info"]) - ) + + return AdjustImageCropCommand(element, tuple(data["old_crop_info"]), tuple(data["new_crop_info"])) class AlignElementsCommand(Command): """Command for aligning multiple elements""" - + def __init__(self, changes: List[tuple]): """ Args: changes: List of (element, old_position) tuples """ self.changes = changes - + def execute(self): """Positions have already been set by AlignmentManager""" pass - + def undo(self): """Restore old positions""" for element, old_position in self.changes: element.position = old_position - + def redo(self): """Re-apply alignment (positions are stored in current state)""" # Store current positions and restore them @@ -488,28 +469,22 @@ class AlignElementsCommand(Command): # Then re-apply new positions for element, new_position in new_positions: element.position = new_position - + def serialize(self) -> Dict[str, Any]: """Serialize to dictionary""" return { "type": "align_elements", - "changes": [ - { - "element": elem.serialize(), - "old_position": old_pos - } - for elem, old_pos in self.changes - ] + "changes": [{"element": elem.serialize(), "old_position": old_pos} for elem, old_pos in self.changes], } - + @staticmethod - def deserialize(data: Dict[str, Any], project) -> 'AlignElementsCommand': + def deserialize(data: Dict[str, Any], project) -> "AlignElementsCommand": """Deserialize from dictionary""" changes = [] for change_data in data.get("changes", []): elem_data = change_data["element"] elem_type = elem_data.get("type") - + if elem_type == "image": element = ImageData() elif elem_type == "placeholder": @@ -518,17 +493,17 @@ class AlignElementsCommand(Command): element = TextBoxData() else: continue - + element.deserialize(elem_data) old_position = tuple(change_data["old_position"]) changes.append((element, old_position)) - + return AlignElementsCommand(changes) class ResizeElementsCommand(Command): """Command for resizing multiple elements""" - + def __init__(self, changes: List[tuple]): """ Args: @@ -536,45 +511,41 @@ class ResizeElementsCommand(Command): """ self.changes = changes self.new_states = [(elem, elem.position, elem.size) for elem, _, _ in changes] - + def execute(self): """Sizes have already been set by AlignmentManager""" pass - + def undo(self): """Restore old positions and sizes""" for element, old_position, old_size in self.changes: element.position = old_position element.size = old_size - + def redo(self): """Re-apply new sizes""" for element, new_position, new_size in self.new_states: element.position = new_position element.size = new_size - + def serialize(self) -> Dict[str, Any]: """Serialize to dictionary""" return { "type": "resize_elements", "changes": [ - { - "element": elem.serialize(), - "old_position": old_pos, - "old_size": old_size - } + {"element": elem.serialize(), "old_position": old_pos, "old_size": old_size} for elem, old_pos, old_size in self.changes - ] + ], } - + @staticmethod - def deserialize(data: Dict[str, Any], project) -> 'ResizeElementsCommand': + def deserialize(data: Dict[str, Any], project) -> "ResizeElementsCommand": """Deserialize from dictionary""" changes = [] for change_data in data.get("changes", []): elem_data = change_data["element"] elem_type = elem_data.get("type") - + if elem_type == "image": element = ImageData() elif elem_type == "placeholder": @@ -583,57 +554,57 @@ class ResizeElementsCommand(Command): element = TextBoxData() else: continue - + element.deserialize(elem_data) old_position = tuple(change_data["old_position"]) old_size = tuple(change_data["old_size"]) changes.append((element, old_position, old_size)) - + return ResizeElementsCommand(changes) class ChangeZOrderCommand(Command): """Command for changing element z-order (list position)""" - + def __init__(self, page_layout, element: BaseLayoutElement, old_index: int, new_index: int): self.page_layout = page_layout self.element = element self.old_index = old_index self.new_index = new_index - + def execute(self): """Move element to new position in list""" elements = self.page_layout.elements if self.element in elements: elements.remove(self.element) elements.insert(self.new_index, self.element) - + def undo(self): """Move element back to old position in list""" elements = self.page_layout.elements if self.element in elements: elements.remove(self.element) elements.insert(self.old_index, self.element) - + def redo(self): """Move element to new position again""" self.execute() - + def serialize(self) -> Dict[str, Any]: """Serialize to dictionary""" return { "type": "change_zorder", "element": self.element.serialize(), "old_index": self.old_index, - "new_index": self.new_index + "new_index": self.new_index, } - + @staticmethod - def deserialize(data: Dict[str, Any], project) -> 'ChangeZOrderCommand': + def deserialize(data: Dict[str, Any], project) -> "ChangeZOrderCommand": """Deserialize from dictionary""" elem_data = data["element"] elem_type = elem_data.get("type") - + if elem_type == "image": element = ImageData() elif elem_type == "placeholder": @@ -642,25 +613,22 @@ class ChangeZOrderCommand(Command): element = TextBoxData() else: raise ValueError(f"Unknown element type: {elem_type}") - + element.deserialize(elem_data) - + return ChangeZOrderCommand( - None, # page_layout will be set by CommandHistory - element, - data["old_index"], - data["new_index"] + None, element, data["old_index"], data["new_index"] # page_layout will be set by CommandHistory ) class StateChangeCommand(Command): """ Generic command for operations that change state. - + This command captures before/after snapshots of state and can restore them. Used by the @undoable_operation decorator. """ - + def __init__(self, description: str, restore_func, before_state: Any, after_state: Any = None): """ Args: @@ -673,31 +641,28 @@ class StateChangeCommand(Command): self.restore_func = restore_func self.before_state = before_state self.after_state = after_state - + def execute(self): """State is already applied, just store after_state if not set""" # After state is captured by decorator after operation runs pass - + def undo(self): """Restore to before state""" self.restore_func(self.before_state) - + def redo(self): """Restore to after state""" self.restore_func(self.after_state) - + def serialize(self) -> Dict[str, Any]: """Serialize to dictionary""" # For now, state change commands are not serialized # This could be enhanced later if needed - return { - "type": "state_change", - "description": self.description - } - + return {"type": "state_change", "description": self.description} + @staticmethod - def deserialize(data: Dict[str, Any], project) -> 'StateChangeCommand': + def deserialize(data: Dict[str, Any], project) -> "StateChangeCommand": """Deserialize from dictionary""" # Not implemented - would need to serialize state raise NotImplementedError("StateChangeCommand deserialization not yet supported") @@ -732,12 +697,12 @@ class CommandHistory: if len(self.undo_stack) > self.max_history: old_cmd = self.undo_stack.pop(0) self._release_command_assets(old_cmd) - + def _release_command_assets(self, command: Command): """Release asset references held by a command""" if not self.asset_manager: return - + # Release asset references for commands that hold them if isinstance(command, (AddElementCommand, DeleteElementCommand)): if isinstance(command.element, ImageData) and command.element.image_path: @@ -746,7 +711,7 @@ class CommandHistory: if os.path.isabs(asset_path): asset_path = os.path.relpath(asset_path, self.asset_manager.project_folder) self.asset_manager.release_reference(asset_path) - + def undo(self) -> bool: """Undo the last command""" if not self.can_undo(): @@ -776,15 +741,15 @@ class CommandHistory: self.project.mark_dirty() return True - + def can_undo(self) -> bool: """Check if undo is available""" return len(self.undo_stack) > 0 - + def can_redo(self) -> bool: """Check if redo is available""" return len(self.redo_stack) > 0 - + def clear(self): """Clear all history and release asset references""" # Release all asset references @@ -792,36 +757,36 @@ class CommandHistory: self._release_command_assets(cmd) for cmd in self.redo_stack: self._release_command_assets(cmd) - + self.undo_stack.clear() self.redo_stack.clear() - + def serialize(self) -> Dict[str, Any]: """Serialize history to dictionary""" return { "undo_stack": [cmd.serialize() for cmd in self.undo_stack], "redo_stack": [cmd.serialize() for cmd in self.redo_stack], - "max_history": self.max_history + "max_history": self.max_history, } - + def deserialize(self, data: Dict[str, Any], project): """Deserialize history from dictionary""" self.max_history = data.get("max_history", 100) - + # Deserialize undo stack self.undo_stack = [] for cmd_data in data.get("undo_stack", []): cmd = self._deserialize_command(cmd_data, project) if cmd: self.undo_stack.append(cmd) - + # Deserialize redo stack self.redo_stack = [] for cmd_data in data.get("redo_stack", []): cmd = self._deserialize_command(cmd_data, project) if cmd: self.redo_stack.append(cmd) - + # Command type registry for deserialization _COMMAND_DESERIALIZERS = { "add_element": AddElementCommand.deserialize, diff --git a/pyPhotoAlbum/decorators.py b/pyPhotoAlbum/decorators.py index 884c3b8..e378c74 100644 --- a/pyPhotoAlbum/decorators.py +++ b/pyPhotoAlbum/decorators.py @@ -10,10 +10,10 @@ from typing import Optional, Callable class RibbonAction: """ Decorator to mark methods as ribbon actions. - + This decorator stores metadata about UI actions that should appear in the ribbon. The metadata is used to auto-generate the ribbon configuration. - + Example: @RibbonAction( label="New", @@ -26,7 +26,7 @@ class RibbonAction: def new_project(self): ... """ - + def __init__( self, label: str, @@ -37,11 +37,11 @@ class RibbonAction: shortcut: Optional[str] = None, requires_page: bool = False, requires_selection: bool = False, - min_selection: int = 0 + min_selection: int = 0, ): """ Initialize the ribbon action decorator. - + Args: label: Button label text tooltip: Tooltip text shown on hover @@ -62,35 +62,36 @@ class RibbonAction: self.requires_page = requires_page self.requires_selection = requires_selection self.min_selection = min_selection - + def __call__(self, func: Callable) -> Callable: """ Decorate the function with ribbon action metadata. - + Args: func: The function to decorate - + Returns: The decorated function with metadata attached """ + @wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) - + # Store metadata on wrapper function wrapper._ribbon_action = { - 'label': self.label, - 'tooltip': self.tooltip, - 'tab': self.tab, - 'group': self.group, - 'icon': self.icon, - 'shortcut': self.shortcut, - 'action': func.__name__, - 'requires_page': self.requires_page, - 'requires_selection': self.requires_selection, - 'min_selection': self.min_selection + "label": self.label, + "tooltip": self.tooltip, + "tab": self.tab, + "group": self.group, + "icon": self.icon, + "shortcut": self.shortcut, + "action": func.__name__, + "requires_page": self.requires_page, + "requires_selection": self.requires_selection, + "min_selection": self.min_selection, } - + return wrapper @@ -103,13 +104,13 @@ def ribbon_action( shortcut: Optional[str] = None, requires_page: bool = False, requires_selection: bool = False, - min_selection: int = 0 + min_selection: int = 0, ) -> Callable: """ Convenience function for the RibbonAction decorator. - + This provides a lowercase function-style interface to the decorator. - + Args: label: Button label text tooltip: Tooltip text shown on hover @@ -120,7 +121,7 @@ def ribbon_action( requires_page: Whether this action requires an active page requires_selection: Whether this action requires selected elements min_selection: Minimum number of selected elements required - + Returns: RibbonAction decorator instance """ @@ -133,17 +134,17 @@ def ribbon_action( shortcut=shortcut, requires_page=requires_page, requires_selection=requires_selection, - min_selection=min_selection + min_selection=min_selection, ) class NumericalInput: """ Decorator to mark methods that require numerical width/height inputs. - + This decorator stores metadata about numerical input fields that should be presented in dialogs for methods that work with page dimensions. - + Example: @numerical_input( fields=[ @@ -154,49 +155,48 @@ class NumericalInput: def set_page_size(self, width, height): ... """ - + def __init__(self, fields: list): """ Initialize the numerical input decorator. - + Args: fields: List of tuples, each containing: (param_name, label, unit, min_value, max_value) """ self.fields = fields - + def __call__(self, func: Callable) -> Callable: """ Decorate the function with numerical input metadata. - + Args: func: The function to decorate - + Returns: The decorated function with metadata attached """ + @wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) - + # Store metadata on wrapper function - wrapper._numerical_input = { - 'fields': self.fields - } - + wrapper._numerical_input = {"fields": self.fields} + return wrapper def numerical_input(fields: list) -> Callable: """ Convenience function for the NumericalInput decorator. - + This provides a lowercase function-style interface to the decorator. - + Args: fields: List of tuples, each containing: (param_name, label, unit, min_value, max_value) - + Returns: NumericalInput decorator instance """ @@ -206,10 +206,10 @@ def numerical_input(fields: list) -> Callable: class UndoableOperation: """ Decorator to automatically create undo/redo commands for operations. - + This decorator captures state before and after an operation, then creates a StateChangeCommand for undo/redo functionality. - + Example: @undoable_operation(capture='page_elements') def apply_template(self): @@ -217,11 +217,11 @@ class UndoableOperation: self.template_manager.apply_template(...) # 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. - + Args: capture: What to capture for undo/redo: - 'page_elements': Capture elements of current page @@ -230,89 +230,92 @@ class UndoableOperation: """ self.capture = capture self.description = description - + def __call__(self, func: Callable) -> Callable: """ Decorate the function with automatic undo/redo. - + Args: func: The function to decorate - + Returns: The decorated function """ + @wraps(func) def wrapper(self_instance, *args, **kwargs): # Get description - description = self.description or func.__name__.replace('_', ' ').title() - + description = self.description or func.__name__.replace("_", " ").title() + # Capture before state before_state = self._capture_state(self_instance, self.capture) - + # Execute the operation result = func(self_instance, *args, **kwargs) - - # Capture after state + + # Capture after state after_state = self._capture_state(self_instance, self.capture) - + # Create restore function def restore_state(state): self._restore_state(self_instance, self.capture, state) # Update view after restoring - if hasattr(self_instance, 'update_view'): + if hasattr(self_instance, "update_view"): self_instance.update_view() - + # Create and execute command from pyPhotoAlbum.commands import StateChangeCommand + cmd = StateChangeCommand(description, restore_state, before_state, after_state) - - if hasattr(self_instance, 'project') and hasattr(self_instance.project, 'history'): + + if hasattr(self_instance, "project") and hasattr(self_instance.project, "history"): self_instance.project.history.execute(cmd) print(f"Undoable operation '{description}' executed") - + return result - + return wrapper - + def _capture_state(self, instance, capture_type: str): """Capture current state based on capture type""" - if capture_type == 'page_elements': + if capture_type == "page_elements": # Capture elements from current page - current_page = instance.get_current_page() if hasattr(instance, 'get_current_page') else None + current_page = instance.get_current_page() if hasattr(instance, "get_current_page") else None if current_page: # Deep copy elements return [copy.deepcopy(elem.serialize()) for elem in current_page.layout.elements] return [] - + return None - + def _restore_state(self, instance, capture_type: str, state): """Restore state based on capture type""" - if capture_type == 'page_elements': + if capture_type == "page_elements": # Restore elements to current page - current_page = instance.get_current_page() if hasattr(instance, 'get_current_page') else None + current_page = instance.get_current_page() if hasattr(instance, "get_current_page") else None if current_page and state is not None: # Clear existing elements current_page.layout.elements.clear() - + # Restore elements from serialized state from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData + for elem_data in state: - elem_type = elem_data.get('type') - if elem_type == 'image': + elem_type = elem_data.get("type") + if elem_type == "image": elem = ImageData() - elif elem_type == 'placeholder': + elif elem_type == "placeholder": elem = PlaceholderData() - elif elem_type == 'textbox': + elif elem_type == "textbox": elem = TextBoxData() else: continue - + elem.deserialize(elem_data) current_page.layout.add_element(elem) -def undoable_operation(capture: str = 'page_elements', description: str = None) -> Callable: +def undoable_operation(capture: str = "page_elements", description: str = None) -> Callable: """ Convenience function for the UndoableOperation decorator. @@ -343,11 +346,7 @@ class DialogAction: self.apply_page_setup(values) """ - def __init__( - self, - dialog_class: type, - requires_pages: bool = True - ): + def __init__(self, dialog_class: type, requires_pages: bool = True): """ Initialize the dialog action decorator. @@ -368,6 +367,7 @@ class DialogAction: Returns: The decorated function """ + @wraps(func) def wrapper(self_instance, *args, **kwargs): # Check preconditions @@ -376,7 +376,7 @@ class DialogAction: # Get initial page index if available initial_page_index = 0 - if hasattr(self_instance, '_get_most_visible_page_index'): + if hasattr(self_instance, "_get_most_visible_page_index"): initial_page_index = self_instance._get_most_visible_page_index() # Create and show dialog @@ -384,17 +384,15 @@ class DialogAction: # Create dialog dialog = self.dialog_class( - parent=self_instance, - project=self_instance.project, - initial_page_index=initial_page_index, - **kwargs + parent=self_instance, project=self_instance.project, initial_page_index=initial_page_index, **kwargs ) # Show dialog and get result from PyQt6.QtWidgets import QDialog + if dialog.exec() == QDialog.DialogCode.Accepted: # Get values from dialog - if hasattr(dialog, 'get_values'): + if hasattr(dialog, "get_values"): values = dialog.get_values() # Call the decorated function with values return func(self_instance, values, *args, **kwargs) @@ -406,10 +404,7 @@ class DialogAction: return wrapper -def dialog_action( - dialog_class: type, - requires_pages: bool = True -) -> Callable: +def dialog_action(dialog_class: type, requires_pages: bool = True) -> Callable: """ Convenience function for the DialogAction decorator. diff --git a/pyPhotoAlbum/dialogs/__init__.py b/pyPhotoAlbum/dialogs/__init__.py index 298a8ac..df46140 100644 --- a/pyPhotoAlbum/dialogs/__init__.py +++ b/pyPhotoAlbum/dialogs/__init__.py @@ -7,4 +7,4 @@ UI presentation logic separately from business logic. from .page_setup_dialog import PageSetupDialog -__all__ = ['PageSetupDialog'] +__all__ = ["PageSetupDialog"] diff --git a/pyPhotoAlbum/dialogs/page_setup_dialog.py b/pyPhotoAlbum/dialogs/page_setup_dialog.py index 65f170f..2244870 100644 --- a/pyPhotoAlbum/dialogs/page_setup_dialog.py +++ b/pyPhotoAlbum/dialogs/page_setup_dialog.py @@ -8,9 +8,16 @@ separating presentation from business logic. import math from typing import Optional, Dict, Any from PyQt6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QLabel, - QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, - QComboBox, QCheckBox + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QDoubleSpinBox, + QSpinBox, + QPushButton, + QGroupBox, + QComboBox, + QCheckBox, ) from pyPhotoAlbum.project import Project @@ -23,12 +30,7 @@ class PageSetupDialog(QDialog): including page size, DPI settings, and cover configuration. """ - def __init__( - self, - parent, - project: Project, - initial_page_index: int = 0 - ): + def __init__(self, parent, project: Project, initial_page_index: int = 0): """ Initialize the page setup dialog. @@ -105,9 +107,7 @@ class PageSetupDialog(QDialog): # Cover checkbox self.cover_checkbox = QCheckBox("Designate as Cover") - self.cover_checkbox.setToolTip( - "Mark this page as the book cover with wrap-around front/spine/back" - ) + self.cover_checkbox.setToolTip("Mark this page as the book cover with wrap-around front/spine/back") layout.addWidget(self.cover_checkbox) # Paper thickness @@ -136,9 +136,7 @@ class PageSetupDialog(QDialog): # Calculated spine width display self.spine_info_label = QLabel() - self.spine_info_label.setStyleSheet( - "font-size: 9pt; color: #0066cc; padding: 5px;" - ) + self.spine_info_label.setStyleSheet("font-size: 9pt; color: #0066cc; padding: 5px;") self.spine_info_label.setWordWrap(True) layout.addWidget(self.spine_info_label) @@ -170,9 +168,7 @@ class PageSetupDialog(QDialog): # Set as default checkbox self.set_default_checkbox = QCheckBox("Set as default for new pages") - self.set_default_checkbox.setToolTip( - "Update project default page size for future pages" - ) + self.set_default_checkbox.setToolTip("Update project default page size for future pages") layout.addWidget(self.set_default_checkbox) group.setLayout(layout) @@ -248,7 +244,7 @@ class PageSetupDialog(QDialog): return selected_page = self.project.pages[index] - is_first_page = (index == 0) + is_first_page = index == 0 # Show/hide cover settings based on page selection self._cover_group.setVisible(is_first_page) @@ -265,7 +261,7 @@ class PageSetupDialog(QDialog): elif selected_page.is_double_spread: display_width = ( selected_page.layout.base_width - if hasattr(selected_page.layout, 'base_width') + if hasattr(selected_page.layout, "base_width") else selected_page.layout.size[0] / 2 ) else: @@ -284,16 +280,12 @@ class PageSetupDialog(QDialog): """Update the spine information display.""" if self.cover_checkbox.isChecked(): # Calculate spine width with current settings - content_pages = sum( - p.get_page_count() for p in self.project.pages if not p.is_cover - ) + content_pages = sum(p.get_page_count() for p in self.project.pages if not p.is_cover) sheets = math.ceil(content_pages / 4) spine_width = sheets * self.thickness_spinbox.value() * 2 page_width = self.project.page_size_mm[0] - total_width = ( - (page_width * 2) + spine_width + (self.bleed_spinbox.value() * 2) - ) + total_width = (page_width * 2) + spine_width + (self.bleed_spinbox.value() * 2) self.spine_info_label.setText( f"Cover Layout: Front ({page_width:.0f}mm) + " @@ -317,14 +309,14 @@ class PageSetupDialog(QDialog): selected_page = self.project.pages[selected_index] return { - 'selected_index': selected_index, - 'selected_page': selected_page, - 'is_cover': self.cover_checkbox.isChecked(), - 'paper_thickness_mm': self.thickness_spinbox.value(), - 'cover_bleed_mm': self.bleed_spinbox.value(), - 'width_mm': self.width_spinbox.value(), - 'height_mm': self.height_spinbox.value(), - 'working_dpi': self.working_dpi_spinbox.value(), - 'export_dpi': self.export_dpi_spinbox.value(), - 'set_as_default': self.set_default_checkbox.isChecked() + "selected_index": selected_index, + "selected_page": selected_page, + "is_cover": self.cover_checkbox.isChecked(), + "paper_thickness_mm": self.thickness_spinbox.value(), + "cover_bleed_mm": self.bleed_spinbox.value(), + "width_mm": self.width_spinbox.value(), + "height_mm": self.height_spinbox.value(), + "working_dpi": self.working_dpi_spinbox.value(), + "export_dpi": self.export_dpi_spinbox.value(), + "set_as_default": self.set_default_checkbox.isChecked(), } diff --git a/pyPhotoAlbum/gl_imports.py b/pyPhotoAlbum/gl_imports.py index 9081c77..be966d5 100644 --- a/pyPhotoAlbum/gl_imports.py +++ b/pyPhotoAlbum/gl_imports.py @@ -24,16 +24,13 @@ try: GL_LINE_LOOP, GL_LINES, GL_TRIANGLE_FAN, - # Colors glColor3f, glColor4f, - # Line state glLineWidth, glLineStipple, GL_LINE_STIPPLE, - # General state glEnable, glDisable, @@ -42,7 +39,6 @@ try: GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, glBlendFunc, - # Textures glGenTextures, glBindTexture, @@ -56,7 +52,6 @@ try: GL_TEXTURE_MAG_FILTER, GL_LINEAR, glTexCoord2f, - # Matrix operations glPushMatrix, glPopMatrix, @@ -64,24 +59,22 @@ try: glTranslatef, glLoadIdentity, glRotatef, - # Clear operations glClear, glClearColor, GL_COLOR_BUFFER_BIT, GL_DEPTH_BUFFER_BIT, - # Viewport glViewport, glMatrixMode, glOrtho, GL_PROJECTION, GL_MODELVIEW, - # Info/debug glGetString, GL_VERSION, ) + GL_AVAILABLE = True except ImportError: diff --git a/pyPhotoAlbum/gl_widget.py b/pyPhotoAlbum/gl_widget.py index 4c137af..0464374 100644 --- a/pyPhotoAlbum/gl_widget.py +++ b/pyPhotoAlbum/gl_widget.py @@ -34,7 +34,7 @@ class GLWidget( MouseInteractionMixin, UndoableInteractionMixin, KeyboardNavigationMixin, - QOpenGLWidget + QOpenGLWidget, ): """OpenGL widget for pyPhotoAlbum rendering and user interaction @@ -77,8 +77,8 @@ class GLWidget( def _get_project_folder(self): """Override AssetPathMixin to access project via main window.""" main_window = self.window() - if hasattr(main_window, 'project') and main_window.project: - return getattr(main_window.project, 'folder_path', None) + if hasattr(main_window, "project") and main_window.project: + return getattr(main_window.project, "folder_path", None) return None def keyPressEvent(self, event): @@ -86,7 +86,7 @@ class GLWidget( if event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace: if self.selected_element: main_window = self.window() - if hasattr(main_window, 'delete_selected_element'): + if hasattr(main_window, "delete_selected_element"): main_window.delete_selected_element() elif event.key() == Qt.Key.Key_Escape: @@ -99,7 +99,7 @@ class GLWidget( if self.selected_element: self.rotation_mode = not self.rotation_mode main_window = self.window() - if hasattr(main_window, 'show_status'): + if hasattr(main_window, "show_status"): mode_text = "Rotation Mode" if self.rotation_mode else "Move/Resize Mode" main_window.show_status(f"Switched to {mode_text}", 2000) print(f"Rotation mode: {self.rotation_mode}") diff --git a/pyPhotoAlbum/image_utils.py b/pyPhotoAlbum/image_utils.py index c863d2d..df6a75f 100644 --- a/pyPhotoAlbum/image_utils.py +++ b/pyPhotoAlbum/image_utils.py @@ -13,6 +13,7 @@ from PIL import Image # Image Processing Utilities # ============================================================================= + def apply_pil_rotation(image: Image.Image, pil_rotation_90: int) -> Image.Image: """ Apply 90-degree rotation increments to a PIL image. @@ -48,8 +49,8 @@ def convert_to_rgba(image: Image.Image) -> Image.Image: Returns: PIL Image in RGBA mode """ - if image.mode != 'RGBA': - return image.convert('RGBA') + if image.mode != "RGBA": + return image.convert("RGBA") return image @@ -58,7 +59,7 @@ def calculate_center_crop_coords( img_height: int, target_width: float, target_height: float, - crop_info: Tuple[float, float, float, float] = (0, 0, 1, 1) + crop_info: Tuple[float, float, float, float] = (0, 0, 1, 1), ) -> Tuple[float, float, float, float]: """ Calculate texture/crop coordinates for center-crop fitting an image to a target aspect ratio. @@ -113,10 +114,7 @@ def calculate_center_crop_coords( return (tx_min, ty_min, tx_max, ty_max) -def crop_image_to_coords( - image: Image.Image, - coords: Tuple[float, float, float, float] -) -> Image.Image: +def crop_image_to_coords(image: Image.Image, coords: Tuple[float, float, float, float]) -> Image.Image: """ Crop an image using normalized texture coordinates. @@ -139,9 +137,7 @@ def crop_image_to_coords( def resize_to_fit( - image: Image.Image, - max_size: int, - resample: Image.Resampling = Image.Resampling.LANCZOS + image: Image.Image, max_size: int, resample: Image.Resampling = Image.Resampling.LANCZOS ) -> Image.Image: """ Resize image to fit within max_size while preserving aspect ratio. diff --git a/pyPhotoAlbum/loading_widget.py b/pyPhotoAlbum/loading_widget.py index a751213..e6f211a 100644 --- a/pyPhotoAlbum/loading_widget.py +++ b/pyPhotoAlbum/loading_widget.py @@ -29,7 +29,8 @@ class LoadingWidget(QWidget): self.setFixedSize(280, 80) # Styling - self.setStyleSheet(""" + self.setStyleSheet( + """ QWidget { background-color: rgba(50, 50, 50, 230); border-radius: 8px; @@ -54,7 +55,8 @@ class LoadingWidget(QWidget): stop:1 rgba(100, 160, 210, 220)); border-radius: 3px; } - """) + """ + ) # Layout layout = QVBoxLayout() diff --git a/pyPhotoAlbum/main.py b/pyPhotoAlbum/main.py index 7da212e..61989ba 100644 --- a/pyPhotoAlbum/main.py +++ b/pyPhotoAlbum/main.py @@ -9,8 +9,14 @@ import sys from datetime import datetime from pathlib import Path from PyQt6.QtWidgets import ( - QApplication, QMainWindow, QVBoxLayout, QWidget, - QStatusBar, QScrollBar, QHBoxLayout, QMessageBox + QApplication, + QMainWindow, + QVBoxLayout, + QWidget, + QStatusBar, + QScrollBar, + QHBoxLayout, + QMessageBox, ) from PyQt6.QtCore import Qt, QSize, QTimer from PyQt6.QtGui import QIcon @@ -58,12 +64,12 @@ class MainWindow( ): """ Main application window using mixin architecture. - + This class composes functionality from multiple mixins rather than implementing everything directly. The ribbon configuration is automatically generated from decorated methods in the mixins. """ - + def __init__(self): super().__init__() @@ -85,8 +91,8 @@ class MainWindow( self._autosave_timer.start(5 * 60 * 1000) # 5 minutes in milliseconds # Add a sample page for demonstration - #self._add_sample_page() - + # self._add_sample_page() + def _init_state(self): """Initialize shared application state""" # Initialize project @@ -94,17 +100,18 @@ class MainWindow( # Set asset resolution context from pyPhotoAlbum.models import set_asset_resolution_context + set_asset_resolution_context(self._project.folder_path) # Initialize template manager self._template_manager = TemplateManager() - + def _init_ui(self): """Initialize user interface""" # Basic window setup self.setWindowTitle("pyPhotoAlbum") self.resize(1200, 800) - + # Set window icon icon_path = Path(__file__).parent / "icons" / "icon.png" print(f"Window icon path: {icon_path}") @@ -113,20 +120,20 @@ class MainWindow( icon = QIcon(str(icon_path)) print(f"Icon is null: {icon.isNull()}") self.setWindowIcon(icon) - + # Create main widget with layout main_widget = QWidget() main_layout = QVBoxLayout() main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) main_widget.setLayout(main_layout) - + # Build ribbon config from decorated methods ribbon_config = build_ribbon_config(self.__class__) - + # Print summary (for debugging) print_ribbon_summary(ribbon_config) - + # Create ribbon with auto-generated config self.ribbon = RibbonWidget(self, ribbon_config) main_layout.addWidget(self.ribbon, 0) @@ -164,13 +171,13 @@ class MainWindow( canvas_widget.setLayout(canvas_layout) main_layout.addWidget(canvas_widget, 1) - + self.setCentralWidget(main_widget) - + # Create status bar self._status_bar = QStatusBar() self.setStatusBar(self._status_bar) - + # Register keyboard shortcuts self._register_shortcuts() @@ -206,8 +213,8 @@ class MainWindow( viewport_width = self._gl_widget.width() viewport_height = self._gl_widget.height() - content_height = bounds['height'] - content_width = bounds['width'] + content_height = bounds["height"] + content_width = bounds["width"] # Vertical scrollbar # Scrollbar value 0 = top of content @@ -257,7 +264,7 @@ class MainWindow( print(f"Registered shortcut: {shortcut_str} -> {method_name}") # Register additional Ctrl+Shift+Z shortcut for redo - if hasattr(self, 'redo'): + if hasattr(self, "redo"): redo_shortcut = QShortcut(QKeySequence("Ctrl+Shift+Z"), self) redo_shortcut.activated.connect(self.redo) print("Registered shortcut: Ctrl+Shift+Z -> redo") @@ -265,31 +272,31 @@ class MainWindow( def resizeEvent(self, event): """Handle window resize to reposition loading widget""" super().resizeEvent(event) - if hasattr(self, '_loading_widget'): + if hasattr(self, "_loading_widget"): self._loading_widget.resizeParent() - + def _add_sample_page(self): """Add a sample page with some elements for demonstration""" from pyPhotoAlbum.project import Page from pyPhotoAlbum.page_layout import PageLayout, GridLayout from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData - + # Create a page with project default size width_mm, height_mm = self.project.page_size_mm page_layout = PageLayout(width=width_mm, height=height_mm) grid = GridLayout(rows=2, columns=2, spacing=20.0) page_layout.set_grid_layout(grid) - + # Add some sample elements (scaled to new default size) image = ImageData(image_path="sample.jpg", x=20, y=20, width=50, height=50) page_layout.add_element(image) - + text_box = TextBoxData(text_content="Sample Text", x=80, y=20, width=50, height=20) page_layout.add_element(text_box) - + placeholder = PlaceholderData(placeholder_type="image", x=20, y=80, width=50, height=50) page_layout.add_element(placeholder) - + # Create and add the page page = Page(layout=page_layout, page_number=1) page.manually_sized = False # Not manually sized, uses defaults @@ -315,8 +322,8 @@ class MainWindow( return checkpoint_path, metadata = checkpoint_info - project_name = metadata.get('project_name', 'Unknown') - timestamp_str = metadata.get('timestamp', 'Unknown time') + project_name = metadata.get("project_name", "Unknown") + timestamp_str = metadata.get("timestamp", "Unknown time") # Parse timestamp for better display try: @@ -334,7 +341,7 @@ class MainWindow( f"Time: {time_display}\n\n" f"Would you like to recover this checkpoint?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.Yes + QMessageBox.StandardButton.Yes, ) if reply == QMessageBox.StandardButton.Yes: @@ -343,7 +350,7 @@ class MainWindow( if success: # Replace current project with recovered one - if hasattr(self, '_project') and self._project: + if hasattr(self, "_project") and self._project: self._project.cleanup() self._project = result @@ -365,8 +372,10 @@ class MainWindow( self, "Unsaved Changes", "You have unsaved changes. Would you like to save before exiting?", - QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Save + | QMessageBox.StandardButton.Discard + | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Save, ) if reply == QMessageBox.StandardButton.Save: @@ -390,7 +399,7 @@ class MainWindow( self.project.cleanup() # Stop autosave timer - if hasattr(self, '_autosave_timer'): + if hasattr(self, "_autosave_timer"): self._autosave_timer.stop() # Cleanup old checkpoints @@ -402,12 +411,12 @@ class MainWindow( def main(): """Application entry point""" app = QApplication(sys.argv) - + # Set application identity for proper taskbar/window manager integration app.setApplicationName("pyPhotoAlbum") app.setApplicationDisplayName("pyPhotoAlbum") app.setDesktopFileName("pyphotoalbum.desktop") - + # Set application icon icon_path = Path(__file__).parent / "icons" / "icon.png" print(f"Application icon path: {icon_path}") @@ -416,17 +425,17 @@ def main(): icon = QIcon(str(icon_path)) print(f"Icon is null: {icon.isNull()}") app.setWindowIcon(icon) - + # Enable high DPI scaling try: app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True) app.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True) except AttributeError: pass # Qt version doesn't support these attributes - + window = MainWindow() window.show() - + sys.exit(app.exec()) diff --git a/pyPhotoAlbum/merge_dialog.py b/pyPhotoAlbum/merge_dialog.py index 8355c62..2918a65 100644 --- a/pyPhotoAlbum/merge_dialog.py +++ b/pyPhotoAlbum/merge_dialog.py @@ -3,9 +3,21 @@ Merge dialog for resolving project conflicts visually """ from PyQt6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, - QListWidget, QListWidgetItem, QSplitter, QWidget, QScrollArea, - QRadioButton, QButtonGroup, QTextEdit, QComboBox, QGroupBox + QDialog, + QVBoxLayout, + QHBoxLayout, + QPushButton, + QLabel, + QListWidget, + QListWidgetItem, + QSplitter, + QWidget, + QScrollArea, + QRadioButton, + QButtonGroup, + QTextEdit, + QComboBox, + QGroupBox, ) from PyQt6.QtCore import Qt, QSize, pyqtSignal from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QPen @@ -22,10 +34,7 @@ class PagePreviewWidget(QWidget): super().__init__(parent) self.page_data = page_data self.setMinimumSize(200, 280) - self.setSizePolicy( - self.sizePolicy().Policy.Expanding, - self.sizePolicy().Policy.Expanding - ) + self.setSizePolicy(self.sizePolicy().Policy.Expanding, self.sizePolicy().Policy.Expanding) def paintEvent(self, event): """Render the page preview""" @@ -356,8 +365,4 @@ class MergeDialog(QDialog): Returns: Merged project data dictionary """ - return self.merge_manager.apply_resolutions( - self.our_project_data, - self.their_project_data, - self.resolutions - ) + return self.merge_manager.apply_resolutions(self.our_project_data, self.their_project_data, self.resolutions) diff --git a/pyPhotoAlbum/merge_manager.py b/pyPhotoAlbum/merge_manager.py index 24ae9dc..ec5b617 100644 --- a/pyPhotoAlbum/merge_manager.py +++ b/pyPhotoAlbum/merge_manager.py @@ -16,6 +16,7 @@ from datetime import datetime, timezone class ConflictType(Enum): """Types of merge conflicts""" + # Page-level conflicts PAGE_MODIFIED_BOTH = "page_modified_both" # Page modified in both versions PAGE_DELETED_ONE = "page_deleted_one" # Page deleted in one version, modified in other @@ -31,6 +32,7 @@ class ConflictType(Enum): class MergeStrategy(Enum): """Automatic merge resolution strategies""" + LATEST_WINS = "latest_wins" # Most recent last_modified wins OURS = "ours" # Always use our version THEIRS = "theirs" # Always use their version @@ -40,6 +42,7 @@ class MergeStrategy(Enum): @dataclass class ConflictInfo: """Information about a single merge conflict""" + conflict_type: ConflictType page_uuid: Optional[str] # UUID of the page (if page-level conflict) element_uuid: Optional[str] # UUID of the element (if element-level conflict) @@ -79,9 +82,7 @@ class MergeManager: return project_a_id == project_b_id def detect_conflicts( - self, - our_project_data: Dict[str, Any], - their_project_data: Dict[str, Any] + self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any] ) -> List[ConflictInfo]: """ Detect conflicts between two versions of the same project. @@ -103,16 +104,18 @@ class MergeManager: return self.conflicts - def _detect_project_settings_conflicts( - self, - our_data: Dict[str, Any], - their_data: Dict[str, Any] - ): + def _detect_project_settings_conflicts(self, our_data: Dict[str, Any], their_data: Dict[str, Any]): """Detect conflicts in project-level settings.""" # Settings that can conflict settings_keys = [ - "name", "page_size_mm", "working_dpi", "export_dpi", - "has_cover", "paper_thickness_mm", "cover_bleed_mm", "binding_type" + "name", + "page_size_mm", + "working_dpi", + "export_dpi", + "has_cover", + "paper_thickness_mm", + "cover_bleed_mm", + "binding_type", ] our_modified = our_data.get("last_modified") @@ -124,20 +127,18 @@ class MergeManager: # If values differ, it's a conflict if our_value != their_value: - self.conflicts.append(ConflictInfo( - conflict_type=ConflictType.SETTINGS_MODIFIED_BOTH, - page_uuid=None, - element_uuid=None, - our_version={key: our_value, "last_modified": our_modified}, - their_version={key: their_value, "last_modified": their_modified}, - description=f"Project setting '{key}' modified in both versions" - )) + self.conflicts.append( + ConflictInfo( + conflict_type=ConflictType.SETTINGS_MODIFIED_BOTH, + page_uuid=None, + element_uuid=None, + our_version={key: our_value, "last_modified": our_modified}, + their_version={key: their_value, "last_modified": their_modified}, + description=f"Project setting '{key}' modified in both versions", + ) + ) - def _detect_page_conflicts( - self, - our_data: Dict[str, Any], - their_data: Dict[str, Any] - ): + def _detect_page_conflicts(self, our_data: Dict[str, Any], their_data: Dict[str, Any]): """Detect conflicts at page level.""" our_pages = {page["uuid"]: page for page in our_data.get("pages", [])} their_pages = {page["uuid"]: page for page in their_data.get("pages", [])} @@ -164,12 +165,7 @@ class MergeManager: # Unless we deleted it pass - def _detect_page_modification_conflicts( - self, - page_uuid: str, - our_page: Dict[str, Any], - their_page: Dict[str, Any] - ): + def _detect_page_modification_conflicts(self, page_uuid: str, our_page: Dict[str, Any], their_page: Dict[str, Any]): """Detect conflicts in a specific page.""" our_modified = our_page.get("last_modified") their_modified = their_page.get("last_modified") @@ -180,14 +176,16 @@ class MergeManager: # Check if one deleted, one modified if our_page.get("deleted") != their_page.get("deleted"): - self.conflicts.append(ConflictInfo( - conflict_type=ConflictType.PAGE_DELETED_ONE, - page_uuid=page_uuid, - element_uuid=None, - our_version=our_page, - their_version=their_page, - description=f"Page deleted in one version but modified in the other" - )) + self.conflicts.append( + ConflictInfo( + conflict_type=ConflictType.PAGE_DELETED_ONE, + page_uuid=page_uuid, + element_uuid=None, + our_version=our_page, + their_version=their_page, + description=f"Page deleted in one version but modified in the other", + ) + ) return # Check page-level properties @@ -201,24 +199,21 @@ class MergeManager: # Only flag as conflict if properties differ AND timestamps are identical # (See element conflict detection for detailed explanation of this strategy) if page_modified and our_modified == their_modified: - self.conflicts.append(ConflictInfo( - conflict_type=ConflictType.PAGE_MODIFIED_BOTH, - page_uuid=page_uuid, - element_uuid=None, - our_version=our_page, - their_version=their_page, - description=f"Page properties modified with same timestamp (possible conflict)" - )) + self.conflicts.append( + ConflictInfo( + conflict_type=ConflictType.PAGE_MODIFIED_BOTH, + page_uuid=page_uuid, + element_uuid=None, + our_version=our_page, + their_version=their_page, + description=f"Page properties modified with same timestamp (possible conflict)", + ) + ) # Check element-level conflicts self._detect_element_conflicts(page_uuid, our_page, their_page) - def _detect_element_conflicts( - self, - page_uuid: str, - our_page: Dict[str, Any], - their_page: Dict[str, Any] - ): + def _detect_element_conflicts(self, page_uuid: str, our_page: Dict[str, Any], their_page: Dict[str, Any]): """Detect conflicts in elements within a page.""" our_layout = our_page.get("layout", {}) their_layout = their_page.get("layout", {}) @@ -238,16 +233,10 @@ class MergeManager: continue # Element exists in both - check for modifications - self._detect_element_modification_conflicts( - page_uuid, elem_uuid, our_elem, their_elem - ) + self._detect_element_modification_conflicts(page_uuid, elem_uuid, our_elem, their_elem) def _detect_element_modification_conflicts( - self, - page_uuid: str, - elem_uuid: str, - our_elem: Dict[str, Any], - their_elem: Dict[str, Any] + self, page_uuid: str, elem_uuid: str, our_elem: Dict[str, Any], their_elem: Dict[str, Any] ): """Detect conflicts in a specific element.""" our_modified = our_elem.get("last_modified") @@ -259,14 +248,16 @@ class MergeManager: # Check if one deleted, one modified if our_elem.get("deleted") != their_elem.get("deleted"): - self.conflicts.append(ConflictInfo( - conflict_type=ConflictType.ELEMENT_DELETED_ONE, - page_uuid=page_uuid, - element_uuid=elem_uuid, - our_version=our_elem, - their_version=their_elem, - description=f"Element deleted in one version but modified in the other" - )) + self.conflicts.append( + ConflictInfo( + conflict_type=ConflictType.ELEMENT_DELETED_ONE, + page_uuid=page_uuid, + element_uuid=elem_uuid, + our_version=our_elem, + their_version=their_elem, + description=f"Element deleted in one version but modified in the other", + ) + ) return # Check element properties @@ -298,22 +289,21 @@ class MergeManager: # Properties differ but timestamps match - this is unusual and might indicate # that both versions modified it at exactly the same time, or there's data corruption. # Flag as conflict to be safe. - self.conflicts.append(ConflictInfo( - conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH, - page_uuid=page_uuid, - element_uuid=elem_uuid, - our_version=our_elem, - their_version=their_elem, - description=f"Element modified with same timestamp (possible conflict)" - )) + self.conflicts.append( + ConflictInfo( + conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH, + page_uuid=page_uuid, + element_uuid=elem_uuid, + our_version=our_elem, + their_version=their_elem, + description=f"Element modified with same timestamp (possible conflict)", + ) + ) # Note: If timestamps differ, we assume one version modified it and the other didn't. # The _merge_non_conflicting_changes method will automatically use the newer version. - def auto_resolve_conflicts( - self, - strategy: MergeStrategy = MergeStrategy.LATEST_WINS - ) -> Dict[str, Any]: + def auto_resolve_conflicts(self, strategy: MergeStrategy = MergeStrategy.LATEST_WINS) -> Dict[str, Any]: """ Automatically resolve conflicts based on a strategy. @@ -353,10 +343,7 @@ class MergeManager: return None def apply_resolutions( - self, - our_project_data: Dict[str, Any], - their_project_data: Dict[str, Any], - resolutions: Dict[int, str] + self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any], resolutions: Dict[int, str] ) -> Dict[str, Any]: """ Apply conflict resolutions to create merged project. @@ -415,20 +402,12 @@ class MergeManager: break break - def _merge_non_conflicting_changes( - self, - merged_data: Dict[str, Any], - their_data: Dict[str, Any] - ): + def _merge_non_conflicting_changes(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]): """Add non-conflicting pages and elements from their version.""" self._add_missing_pages(merged_data, their_data) self._merge_page_elements(merged_data, their_data) - def _add_missing_pages( - self, - merged_data: Dict[str, Any], - their_data: Dict[str, Any] - ): + def _add_missing_pages(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]): """Add pages that exist only in their version.""" our_page_uuids = {page["uuid"] for page in merged_data.get("pages", [])} @@ -436,11 +415,7 @@ class MergeManager: if their_page["uuid"] not in our_page_uuids: merged_data["pages"].append(their_page) - def _merge_page_elements( - self, - merged_data: Dict[str, Any], - their_data: Dict[str, Any] - ): + def _merge_page_elements(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]): """For pages that exist in both versions, merge their elements.""" their_pages = {page["uuid"]: page for page in their_data.get("pages", [])} @@ -449,25 +424,15 @@ class MergeManager: if not their_page: continue - our_elements = { - elem["uuid"]: elem - for elem in our_page.get("layout", {}).get("elements", []) - } + our_elements = {elem["uuid"]: elem for elem in our_page.get("layout", {}).get("elements", [])} for their_elem in their_page.get("layout", {}).get("elements", []): self._merge_element( - our_page=our_page, - page_uuid=our_page["uuid"], - their_elem=their_elem, - our_elements=our_elements + our_page=our_page, page_uuid=our_page["uuid"], their_elem=their_elem, our_elements=our_elements ) def _merge_element( - self, - our_page: Dict[str, Any], - page_uuid: str, - their_elem: Dict[str, Any], - our_elements: Dict[str, Any] + self, our_page: Dict[str, Any], page_uuid: str, their_elem: Dict[str, Any], our_elements: Dict[str, Any] ): """Merge a single element from their version into our page.""" elem_uuid = their_elem["uuid"] @@ -486,17 +451,10 @@ class MergeManager: def _is_element_in_conflict(self, elem_uuid: str, page_uuid: str) -> bool: """Check if element was part of a conflict that was already resolved.""" - return any( - c.element_uuid == elem_uuid and c.page_uuid == page_uuid - for c in self.conflicts - ) + return any(c.element_uuid == elem_uuid and c.page_uuid == page_uuid for c in self.conflicts) def _merge_by_timestamp( - self, - our_page: Dict[str, Any], - elem_uuid: str, - their_elem: Dict[str, Any], - our_elem: Dict[str, Any] + self, our_page: Dict[str, Any], elem_uuid: str, their_elem: Dict[str, Any], our_elem: Dict[str, Any] ): """Use the more recently modified version of an element.""" our_modified = our_elem.get("last_modified") @@ -513,10 +471,7 @@ class MergeManager: break -def concatenate_projects( - project_a_data: Dict[str, Any], - project_b_data: Dict[str, Any] -) -> Dict[str, Any]: +def concatenate_projects(project_a_data: Dict[str, Any], project_b_data: Dict[str, Any]) -> Dict[str, Any]: """ Concatenate two projects with different project_ids. @@ -542,6 +497,8 @@ def concatenate_projects( # Update last_modified to now merged_data["last_modified"] = datetime.now(timezone.utc).isoformat() - print(f"Concatenated projects: {len(project_a_data.get('pages', []))} + {len(project_b_data.get('pages', []))} = {len(merged_data['pages'])} pages") + print( + f"Concatenated projects: {len(project_a_data.get('pages', []))} + {len(project_b_data.get('pages', []))} = {len(merged_data['pages'])} pages" + ) return merged_data diff --git a/pyPhotoAlbum/mixins/__init__.py b/pyPhotoAlbum/mixins/__init__.py index 35c8f9b..2c2696a 100644 --- a/pyPhotoAlbum/mixins/__init__.py +++ b/pyPhotoAlbum/mixins/__init__.py @@ -5,4 +5,4 @@ Mixin modules for pyPhotoAlbum from pyPhotoAlbum.mixins.base import ApplicationStateMixin from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin -__all__ = ['ApplicationStateMixin', 'DialogMixin'] +__all__ = ["ApplicationStateMixin", "DialogMixin"] diff --git a/pyPhotoAlbum/mixins/asset_drop.py b/pyPhotoAlbum/mixins/asset_drop.py index 3408103..651322b 100644 --- a/pyPhotoAlbum/mixins/asset_drop.py +++ b/pyPhotoAlbum/mixins/asset_drop.py @@ -14,7 +14,7 @@ class AssetDropMixin: or updating ImageData elements. """ - IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'] + IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"] def dragEnterEvent(self, event): """Handle drag enter events""" @@ -66,7 +66,7 @@ class AssetDropMixin: def _handle_drop_on_element(self, image_path, target_element): """Handle dropping an image onto an existing element""" main_window = self.window() - if not (hasattr(main_window, 'project') and main_window.project): + if not (hasattr(main_window, "project") and main_window.project): return try: @@ -89,7 +89,7 @@ class AssetDropMixin: y=placeholder.position[1], width=placeholder.size[0], height=placeholder.size[1], - z_index=placeholder.z_index + z_index=placeholder.z_index, ) if not main_window.project.pages: @@ -104,7 +104,7 @@ class AssetDropMixin: def _handle_drop_on_empty_space(self, image_path, x, y): """Handle dropping an image onto empty space""" main_window = self.window() - if not (hasattr(main_window, 'project') and main_window.project and main_window.project.pages): + if not (hasattr(main_window, "project") and main_window.project and main_window.project.pages): return target_page, page_index, page_renderer = self._get_page_at(x, y) @@ -120,8 +120,7 @@ class AssetDropMixin: img_width, img_height = self._calculate_image_dimensions(full_asset_path) self._add_new_image_to_page( - asset_path, target_page, page_index, page_renderer, - x, y, img_width, img_height, main_window + asset_path, target_page, page_index, page_renderer, x, y, img_width, img_height, main_window ) except Exception as e: print(f"Error importing dropped image: {e}") @@ -138,27 +137,18 @@ class AssetDropMixin: # Fallback dimensions if image cannot be read return 200, 150 - def _add_new_image_to_page(self, asset_path, target_page, page_index, - page_renderer, x, y, img_width, img_height, main_window): + def _add_new_image_to_page( + self, asset_path, target_page, page_index, page_renderer, x, y, img_width, img_height, main_window + ): """Add a new image element to the target page (asset already imported)""" if page_index >= 0: self.current_page_index = page_index page_local_x, page_local_y = page_renderer.screen_to_page(x, y) - new_image = ImageData( - image_path=asset_path, - x=page_local_x, - y=page_local_y, - width=img_width, - height=img_height - ) + new_image = ImageData(image_path=asset_path, x=page_local_x, y=page_local_y, width=img_width, height=img_height) - cmd = AddElementCommand( - target_page.layout, - new_image, - asset_manager=main_window.project.asset_manager - ) + cmd = AddElementCommand(target_page.layout, new_image, asset_manager=main_window.project.asset_manager) main_window.project.history.execute(cmd) print(f"Added new image to page {page_index + 1} at ({page_local_x:.1f}, {page_local_y:.1f}): {asset_path}") diff --git a/pyPhotoAlbum/mixins/asset_path.py b/pyPhotoAlbum/mixins/asset_path.py index 6a082e1..00c0483 100644 --- a/pyPhotoAlbum/mixins/asset_path.py +++ b/pyPhotoAlbum/mixins/asset_path.py @@ -63,6 +63,6 @@ class AssetPathMixin: Override this method if the project is accessed differently. Default implementation uses self.project.folder_path. """ - if hasattr(self, 'project') and self.project: - return getattr(self.project, 'folder_path', None) + if hasattr(self, "project") and self.project: + return getattr(self.project, "folder_path", None) return None diff --git a/pyPhotoAlbum/mixins/async_loading.py b/pyPhotoAlbum/mixins/async_loading.py index e03f929..abfa696 100644 --- a/pyPhotoAlbum/mixins/async_loading.py +++ b/pyPhotoAlbum/mixins/async_loading.py @@ -48,13 +48,13 @@ class AsyncLoadingMixin: """Cleanup async loading components.""" logger.info("Cleaning up async loading system...") - if hasattr(self, 'async_image_loader'): + if hasattr(self, "async_image_loader"): self.async_image_loader.stop() - if hasattr(self, 'async_pdf_generator'): + if hasattr(self, "async_pdf_generator"): self.async_pdf_generator.stop() - if hasattr(self, 'image_cache'): + if hasattr(self, "image_cache"): self.image_cache.clear() logger.info("Async loading system cleaned up") @@ -70,7 +70,7 @@ class AsyncLoadingMixin: """ logger.debug(f"Image loaded callback: {path}") - if user_data and hasattr(user_data, '_on_async_image_loaded'): + if user_data and hasattr(user_data, "_on_async_image_loaded"): user_data._on_async_image_loaded(image) # Trigger re-render to show newly loaded image @@ -87,7 +87,7 @@ class AsyncLoadingMixin: """ logger.warning(f"Image load failed: {path} - {error_msg}") - if user_data and hasattr(user_data, '_on_async_image_load_failed'): + if user_data and hasattr(user_data, "_on_async_image_load_failed"): user_data._on_async_image_load_failed(error_msg) def _on_pdf_progress(self, current: int, total: int, message: str): @@ -102,7 +102,7 @@ class AsyncLoadingMixin: logger.debug(f"PDF progress: {current}/{total} - {message}") # Update progress dialog if it exists - if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog: + if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog: self._pdf_progress_dialog.setValue(current) self._pdf_progress_dialog.setLabelText(message) @@ -117,19 +117,16 @@ class AsyncLoadingMixin: logger.info(f"PDF export complete: success={success}, warnings={len(warnings)}") # Close progress dialog - if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog: + if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog: self._pdf_progress_dialog.close() self._pdf_progress_dialog = None # Show completion message main_window = self.window() - if hasattr(main_window, 'show_status'): + if hasattr(main_window, "show_status"): if success: if warnings: - main_window.show_status( - f"PDF exported successfully with {len(warnings)} warnings", - 5000 - ) + main_window.show_status(f"PDF exported successfully with {len(warnings)} warnings", 5000) else: main_window.show_status("PDF exported successfully", 3000) else: @@ -145,13 +142,13 @@ class AsyncLoadingMixin: logger.error(f"PDF export failed: {error_msg}") # Close progress dialog - if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog: + if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog: self._pdf_progress_dialog.close() self._pdf_progress_dialog = None # Show error message main_window = self.window() - if hasattr(main_window, 'show_status'): + if hasattr(main_window, "show_status"): main_window.show_status(f"PDF export failed: {error_msg}", 5000) def request_image_load(self, image_data, priority: LoadPriority = LoadPriority.NORMAL): @@ -162,7 +159,7 @@ class AsyncLoadingMixin: image_data: ImageData element to load priority: Load priority level """ - if not hasattr(self, 'async_image_loader'): + if not hasattr(self, "async_image_loader"): logger.warning("Async image loader not initialized") return @@ -188,7 +185,7 @@ class AsyncLoadingMixin: Path(image_full_path), priority=priority, target_size=target_size, - user_data=image_data # Pass element for callback + user_data=image_data, # Pass element for callback ) def export_pdf_async(self, project, output_path: str, export_dpi: int = 300): @@ -200,7 +197,7 @@ class AsyncLoadingMixin: output_path: Output PDF file path export_dpi: Export DPI (default 300) """ - if not hasattr(self, 'async_pdf_generator'): + if not hasattr(self, "async_pdf_generator"): logger.warning("Async PDF generator not initialized") return False @@ -208,18 +205,9 @@ class AsyncLoadingMixin: from PyQt6.QtWidgets import QProgressDialog from PyQt6.QtCore import Qt - total_pages = sum( - 1 if page.is_cover else (2 if page.is_double_spread else 1) - for page in project.pages - ) + total_pages = sum(1 if page.is_cover else (2 if page.is_double_spread else 1) for page in project.pages) - self._pdf_progress_dialog = QProgressDialog( - "Exporting to PDF...", - "Cancel", - 0, - total_pages, - self - ) + self._pdf_progress_dialog = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, self) self._pdf_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal) self._pdf_progress_dialog.setWindowTitle("PDF Export") self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel) @@ -232,17 +220,17 @@ class AsyncLoadingMixin: """Handle PDF export cancellation.""" logger.info("User requested PDF export cancellation") - if hasattr(self, 'async_pdf_generator'): + if hasattr(self, "async_pdf_generator"): self.async_pdf_generator.cancel_export() def get_async_stats(self) -> dict: """Get async loading system statistics.""" stats = {} - if hasattr(self, 'async_image_loader'): - stats['image_loader'] = self.async_image_loader.get_stats() + if hasattr(self, "async_image_loader"): + stats["image_loader"] = self.async_image_loader.get_stats() - if hasattr(self, 'async_pdf_generator'): - stats['pdf_generator'] = self.async_pdf_generator.get_stats() + if hasattr(self, "async_pdf_generator"): + stats["pdf_generator"] = self.async_pdf_generator.get_stats() return stats diff --git a/pyPhotoAlbum/mixins/base.py b/pyPhotoAlbum/mixins/base.py index 63be57a..1b044c0 100644 --- a/pyPhotoAlbum/mixins/base.py +++ b/pyPhotoAlbum/mixins/base.py @@ -9,50 +9,50 @@ from PyQt6.QtWidgets import QStatusBar, QMessageBox class ApplicationStateMixin: """ Base mixin providing access to shared application state. - + This mixin provides properties and helper methods for accessing core application objects that are shared across all operation mixins. - + Required attributes (must be set by MainWindow): _project: Project instance _gl_widget: GLWidget instance _status_bar: QStatusBar instance _template_manager: TemplateManager instance """ - + @property def project(self): """Access to current project""" - if not hasattr(self, '_project'): + if not hasattr(self, "_project"): raise AttributeError("MainWindow must set _project attribute") return self._project - + @project.setter def project(self, value): """Set the current project""" self._project = value - + @property def gl_widget(self): """Access to GL rendering widget""" - if not hasattr(self, '_gl_widget'): + if not hasattr(self, "_gl_widget"): raise AttributeError("MainWindow must set _gl_widget attribute") return self._gl_widget - + @property def status_bar(self) -> QStatusBar: """Access to status bar""" - if not hasattr(self, '_status_bar'): + if not hasattr(self, "_status_bar"): raise AttributeError("MainWindow must set _status_bar attribute") return self._status_bar - + @property def template_manager(self): """Access to template manager""" - if not hasattr(self, '_template_manager'): + if not hasattr(self, "_template_manager"): raise AttributeError("MainWindow must set _template_manager attribute") return self._template_manager - + # Common helper methods def _get_most_visible_page_index(self): @@ -62,7 +62,7 @@ class ApplicationStateMixin: Returns: int: Index of the most visible page """ - if not hasattr(self.gl_widget, '_page_renderers') or not self.gl_widget._page_renderers: + if not hasattr(self.gl_widget, "_page_renderers") or not self.gl_widget._page_renderers: return self.gl_widget.current_page_index # Get viewport dimensions @@ -70,7 +70,7 @@ class ApplicationStateMixin: viewport_center_y = viewport_height / 2 # Find which page's center is closest to viewport center - min_distance = float('inf') + min_distance = float("inf") best_page_index = self.gl_widget.current_page_index for renderer, page in self.gl_widget._page_renderers: @@ -107,109 +107,106 @@ class ApplicationStateMixin: return self.project.pages[index] return None - + def get_current_page_index(self) -> int: """ Get current page index. - + Returns: Current page index, or -1 if no page """ if not self.project or not self.project.pages: return -1 return self.gl_widget.current_page_index - + def show_status(self, message: str, timeout: int = 2000): """ Show message in status bar. - + Args: message: Message to display timeout: Display duration in milliseconds """ if self.status_bar: self.status_bar.showMessage(message, timeout) - + def show_error(self, title: str, message: str): """ Show error dialog. - + Args: title: Dialog title message: Error message """ QMessageBox.critical(self, title, message) - + def show_warning(self, title: str, message: str): """ Show warning dialog. - + Args: title: Dialog title message: Warning message """ QMessageBox.warning(self, title, message) - + def show_info(self, title: str, message: str): """ Show information dialog. - + Args: title: Dialog title message: Information message """ QMessageBox.information(self, title, message) - + def require_page(self, show_warning: bool = True) -> bool: """ Check if a page is available and optionally show warning. - + Args: show_warning: Whether to show warning dialog if no page exists - + Returns: True if page exists, False otherwise """ current_page = self.get_current_page() - + if current_page is None: if show_warning: self.show_warning("No Page", "Please create a page first.") return False - + return True - + def require_selection(self, min_count: int = 1, show_warning: bool = True) -> bool: """ Check if required number of elements are selected. - + Args: min_count: Minimum number of selected elements required show_warning: Whether to show warning dialog if requirement not met - + Returns: True if requirements met, False otherwise """ selected_count = len(self.gl_widget.selected_elements) - + if selected_count < min_count: if show_warning: if min_count == 1: self.show_info("No Selection", "Please select an element.") else: - self.show_info( - "Selection Required", - f"Please select at least {min_count} elements." - ) + self.show_info("Selection Required", f"Please select at least {min_count} elements.") return False - + return True - + def update_view(self): """Trigger GL widget update to refresh the view""" if self.gl_widget: self.gl_widget.update() # Update scrollbars to reflect new content - if hasattr(self, 'update_scrollbars'): + if hasattr(self, "update_scrollbars"): self.update_scrollbars() diff --git a/pyPhotoAlbum/mixins/dialog_mixin.py b/pyPhotoAlbum/mixins/dialog_mixin.py index 114ac81..2def4a9 100644 --- a/pyPhotoAlbum/mixins/dialog_mixin.py +++ b/pyPhotoAlbum/mixins/dialog_mixin.py @@ -16,12 +16,7 @@ class DialogMixin: making it easier to create, test, and maintain complex dialogs. """ - def create_dialog( - self, - dialog_class: type, - title: Optional[str] = None, - **kwargs - ) -> Optional[Any]: + def create_dialog(self, dialog_class: type, title: Optional[str] = None, **kwargs) -> Optional[Any]: """ Create and show a dialog, handling the result. @@ -43,18 +38,13 @@ class DialogMixin: # Show dialog and handle result if dialog.exec() == QDialog.DialogCode.Accepted: # Check if dialog has a get_values method - if hasattr(dialog, 'get_values'): + if hasattr(dialog, "get_values"): return dialog.get_values() return True return None - def show_dialog( - self, - dialog_class: type, - on_accept: Optional[Callable] = None, - **kwargs - ) -> bool: + def show_dialog(self, dialog_class: type, on_accept: Optional[Callable] = None, **kwargs) -> bool: """ Show a dialog and execute callback on acceptance. diff --git a/pyPhotoAlbum/mixins/element_manipulation.py b/pyPhotoAlbum/mixins/element_manipulation.py index 1a014fa..6cde1e7 100644 --- a/pyPhotoAlbum/mixins/element_manipulation.py +++ b/pyPhotoAlbum/mixins/element_manipulation.py @@ -27,11 +27,7 @@ class ElementManipulationMixin: self.rotation_snap_angle: int = 15 # Default snap angle in degrees # Snap state tracking - self.snap_state = { - 'is_snapped': False, - 'last_position': None, - 'last_size': None - } + self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None} def _resize_element(self, dx: float, dy: float): """ @@ -49,7 +45,7 @@ class ElementManipulationMixin: # Get the snapping system from the element's parent page main_window = self.window() - if not hasattr(self.selected_element, '_parent_page'): + if not hasattr(self.selected_element, "_parent_page"): self._resize_element_no_snap(dx, dy) return @@ -62,6 +58,7 @@ class ElementManipulationMixin: # Apply snapping to resize from pyPhotoAlbum.snapping import SnapResizeParams + params = SnapResizeParams( position=self.resize_start_pos, size=self.resize_start_size, @@ -70,7 +67,7 @@ class ElementManipulationMixin: resize_handle=self.resize_handle, page_size=page_size, dpi=dpi, - project=main_window.project + project=main_window.project, ) new_pos, new_size = snap_sys.snap_resize(params) @@ -100,16 +97,16 @@ class ElementManipulationMixin: start_x, start_y = self.resize_start_pos start_w, start_h = self.resize_start_size - if self.resize_handle == 'nw': + if self.resize_handle == "nw": self.selected_element.position = (start_x + dx, start_y + dy) self.selected_element.size = (start_w - dx, start_h - dy) - elif self.resize_handle == 'ne': + elif self.resize_handle == "ne": self.selected_element.position = (start_x, start_y + dy) self.selected_element.size = (start_w + dx, start_h - dy) - elif self.resize_handle == 'sw': + elif self.resize_handle == "sw": self.selected_element.position = (start_x + dx, start_y) self.selected_element.size = (start_w - dx, start_h + dy) - elif self.resize_handle == 'se': + elif self.resize_handle == "se": self.selected_element.size = (start_w + dx, start_h + dy) # Ensure minimum size @@ -121,7 +118,9 @@ class ElementManipulationMixin: w, _ = self.selected_element.size self.selected_element.size = (w, min_size) - def _transfer_element_to_page(self, element, source_page, target_page, mouse_x: float, mouse_y: float, target_renderer): + def _transfer_element_to_page( + self, element, source_page, target_page, mouse_x: float, mouse_y: float, target_renderer + ): """ Transfer an element from one page to another during drag operation. diff --git a/pyPhotoAlbum/mixins/element_selection.py b/pyPhotoAlbum/mixins/element_selection.py index 6766a3a..8f671c3 100644 --- a/pyPhotoAlbum/mixins/element_selection.py +++ b/pyPhotoAlbum/mixins/element_selection.py @@ -54,7 +54,7 @@ class ElementSelectionMixin: Returns: BaseLayoutElement or None: The topmost element at the position, or None """ - if not hasattr(self, '_page_renderers') or not self._page_renderers: + if not hasattr(self, "_page_renderers") or not self._page_renderers: return None # Check each page from top to bottom (reverse z-order) @@ -96,11 +96,11 @@ class ElementSelectionMixin: return None main_window = self.window() - if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: + if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages: return None # Get the PageRenderer for this element (stored when element was selected) - if not hasattr(self.selected_element, '_page_renderer'): + if not hasattr(self.selected_element, "_page_renderer"): return None renderer = self.selected_element._page_renderer @@ -117,10 +117,10 @@ class ElementSelectionMixin: # Check handles (no rotation transformation needed - images are already rotated) handles = { - 'nw': (ex - handle_size/2, ey - handle_size/2), - 'ne': (ex + ew - handle_size/2, ey - handle_size/2), - 'sw': (ex - handle_size/2, ey + eh - handle_size/2), - 'se': (ex + ew - handle_size/2, ey + eh - handle_size/2), + "nw": (ex - handle_size / 2, ey - handle_size / 2), + "ne": (ex + ew - handle_size / 2, ey - handle_size / 2), + "sw": (ex - handle_size / 2, ey + eh - handle_size / 2), + "se": (ex + ew - handle_size / 2, ey + eh - handle_size / 2), } for name, (hx, hy) in handles.items(): diff --git a/pyPhotoAlbum/mixins/interaction_command_builders.py b/pyPhotoAlbum/mixins/interaction_command_builders.py index e1e8c57..b1d4687 100644 --- a/pyPhotoAlbum/mixins/interaction_command_builders.py +++ b/pyPhotoAlbum/mixins/interaction_command_builders.py @@ -59,7 +59,7 @@ class MoveCommandBuilder(CommandBuilder): def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool: """Check if position changed significantly.""" - old_pos = start_state.get('position') + old_pos = start_state.get("position") if old_pos is None: return False @@ -68,7 +68,7 @@ class MoveCommandBuilder(CommandBuilder): def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]: """Build a MoveElementCommand.""" - old_pos = start_state.get('position') + old_pos = start_state.get("position") if old_pos is None: return None @@ -79,6 +79,7 @@ class MoveCommandBuilder(CommandBuilder): return None from pyPhotoAlbum.commands import MoveElementCommand + command = MoveElementCommand(element, old_pos, new_pos) self.log_command("Move", f"{old_pos} → {new_pos}") @@ -90,8 +91,8 @@ class ResizeCommandBuilder(CommandBuilder): def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool: """Check if position or size changed significantly.""" - old_pos = start_state.get('position') - old_size = start_state.get('size') + old_pos = start_state.get("position") + old_size = start_state.get("size") if old_pos is None or old_size is None: return False @@ -106,8 +107,8 @@ class ResizeCommandBuilder(CommandBuilder): def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]: """Build a ResizeElementCommand.""" - old_pos = start_state.get('position') - old_size = start_state.get('size') + old_pos = start_state.get("position") + old_size = start_state.get("size") if old_pos is None or old_size is None: return None @@ -119,6 +120,7 @@ class ResizeCommandBuilder(CommandBuilder): return None from pyPhotoAlbum.commands import ResizeElementCommand + command = ResizeElementCommand(element, old_pos, old_size, new_pos, new_size) self.log_command("Resize", f"{old_size} → {new_size}") @@ -130,7 +132,7 @@ class RotateCommandBuilder(CommandBuilder): def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool: """Check if rotation changed significantly.""" - old_rotation = start_state.get('rotation') + old_rotation = start_state.get("rotation") if old_rotation is None: return False @@ -139,7 +141,7 @@ class RotateCommandBuilder(CommandBuilder): def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]: """Build a RotateElementCommand.""" - old_rotation = start_state.get('rotation') + old_rotation = start_state.get("rotation") if old_rotation is None: return None @@ -150,6 +152,7 @@ class RotateCommandBuilder(CommandBuilder): return None from pyPhotoAlbum.commands import RotateElementCommand + command = RotateElementCommand(element, old_rotation, new_rotation) self.log_command("Rotation", f"{old_rotation:.1f}° → {new_rotation:.1f}°") @@ -166,7 +169,7 @@ class ImagePanCommandBuilder(CommandBuilder): if not isinstance(element, ImageData): return False - old_crop = start_state.get('crop_info') + old_crop = start_state.get("crop_info") if old_crop is None: return False @@ -181,7 +184,7 @@ class ImagePanCommandBuilder(CommandBuilder): if not isinstance(element, ImageData): return None - old_crop = start_state.get('crop_info') + old_crop = start_state.get("crop_info") if old_crop is None: return None @@ -193,6 +196,7 @@ class ImagePanCommandBuilder(CommandBuilder): return None from pyPhotoAlbum.commands import AdjustImageCropCommand + command = AdjustImageCropCommand(element, old_crop, new_crop) self.log_command("Image pan", f"{old_crop} → {new_crop}") diff --git a/pyPhotoAlbum/mixins/interaction_command_factory.py b/pyPhotoAlbum/mixins/interaction_command_factory.py index 46958a0..693b465 100644 --- a/pyPhotoAlbum/mixins/interaction_command_factory.py +++ b/pyPhotoAlbum/mixins/interaction_command_factory.py @@ -12,7 +12,7 @@ from .interaction_command_builders import ( MoveCommandBuilder, ResizeCommandBuilder, RotateCommandBuilder, - ImagePanCommandBuilder + ImagePanCommandBuilder, ) @@ -31,10 +31,10 @@ class InteractionCommandFactory: def _register_default_builders(self): """Register the default command builders.""" - self.register_builder('move', MoveCommandBuilder()) - self.register_builder('resize', ResizeCommandBuilder()) - self.register_builder('rotate', RotateCommandBuilder()) - self.register_builder('image_pan', ImagePanCommandBuilder()) + self.register_builder("move", MoveCommandBuilder()) + self.register_builder("resize", ResizeCommandBuilder()) + self.register_builder("rotate", RotateCommandBuilder()) + self.register_builder("image_pan", ImagePanCommandBuilder()) def register_builder(self, interaction_type: str, builder: CommandBuilder): """ @@ -46,11 +46,9 @@ class InteractionCommandFactory: """ self._builders[interaction_type] = builder - def create_command(self, - interaction_type: str, - element: BaseLayoutElement, - start_state: dict, - **kwargs) -> Optional[Any]: + def create_command( + self, interaction_type: str, element: BaseLayoutElement, start_state: dict, **kwargs + ) -> Optional[Any]: """ Create a command based on interaction type and state changes. @@ -91,13 +89,15 @@ class InteractionState: the code more maintainable. """ - def __init__(self, - element: Optional[BaseLayoutElement] = None, - interaction_type: Optional[str] = None, - position: Optional[tuple] = None, - size: Optional[tuple] = None, - rotation: Optional[float] = None, - crop_info: Optional[tuple] = None): + def __init__( + self, + element: Optional[BaseLayoutElement] = None, + interaction_type: Optional[str] = None, + position: Optional[tuple] = None, + size: Optional[tuple] = None, + rotation: Optional[float] = None, + crop_info: Optional[tuple] = None, + ): """ Initialize interaction state. @@ -125,13 +125,13 @@ class InteractionState: """ state = {} if self.position is not None: - state['position'] = self.position + state["position"] = self.position if self.size is not None: - state['size'] = self.size + state["size"] = self.size if self.rotation is not None: - state['rotation'] = self.rotation + state["rotation"] = self.rotation if self.crop_info is not None: - state['crop_info'] = self.crop_info + state["crop_info"] = self.crop_info return state def is_valid(self) -> bool: diff --git a/pyPhotoAlbum/mixins/interaction_undo.py b/pyPhotoAlbum/mixins/interaction_undo.py index 2eb20eb..01ead49 100644 --- a/pyPhotoAlbum/mixins/interaction_undo.py +++ b/pyPhotoAlbum/mixins/interaction_undo.py @@ -10,12 +10,12 @@ from .interaction_command_factory import InteractionCommandFactory, InteractionS class UndoableInteractionMixin: """ Mixin providing automatic undo/redo for interactive mouse operations. - + This mixin tracks the state of elements before interactive operations (move, resize, rotate) and automatically creates appropriate Command objects when the interaction completes. """ - + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -24,7 +24,7 @@ class UndoableInteractionMixin: # Interaction state tracking self._interaction_state = InteractionState() - + def _begin_move(self, element: BaseLayoutElement): """ Begin tracking a move operation. @@ -33,9 +33,9 @@ class UndoableInteractionMixin: element: The element being moved """ self._interaction_state.element = element - self._interaction_state.interaction_type = 'move' + self._interaction_state.interaction_type = "move" self._interaction_state.position = element.position - + def _begin_resize(self, element: BaseLayoutElement): """ Begin tracking a resize operation. @@ -44,10 +44,10 @@ class UndoableInteractionMixin: element: The element being resized """ self._interaction_state.element = element - self._interaction_state.interaction_type = 'resize' + self._interaction_state.interaction_type = "resize" self._interaction_state.position = element.position self._interaction_state.size = element.size - + def _begin_rotate(self, element: BaseLayoutElement): """ Begin tracking a rotate operation. @@ -56,9 +56,9 @@ class UndoableInteractionMixin: element: The element being rotated """ self._interaction_state.element = element - self._interaction_state.interaction_type = 'rotate' + self._interaction_state.interaction_type = "rotate" self._interaction_state.rotation = element.rotation - + def _begin_image_pan(self, element): """ Begin tracking an image pan operation. @@ -67,13 +67,14 @@ class UndoableInteractionMixin: element: The ImageData element being panned """ from pyPhotoAlbum.models import ImageData + if not isinstance(element, ImageData): return self._interaction_state.element = element - self._interaction_state.interaction_type = 'image_pan' + self._interaction_state.interaction_type = "image_pan" self._interaction_state.crop_info = element.crop_info - + def _end_interaction(self): """ End the current interaction and create appropriate undo/redo command. @@ -88,7 +89,7 @@ class UndoableInteractionMixin: # Get main window to access project history main_window = self.window() - if not hasattr(main_window, 'project'): + if not hasattr(main_window, "project"): self._clear_interaction_state() return @@ -96,7 +97,7 @@ class UndoableInteractionMixin: command = self._command_factory.create_command( interaction_type=self._interaction_state.interaction_type, element=self._interaction_state.element, - start_state=self._interaction_state.to_dict() + start_state=self._interaction_state.to_dict(), ) # Execute the command through history if one was created @@ -105,11 +106,11 @@ class UndoableInteractionMixin: # Clear interaction state self._clear_interaction_state() - + def _clear_interaction_state(self): """Clear all interaction tracking state""" self._interaction_state.clear() - + def _cancel_interaction(self): """Cancel the current interaction without creating a command""" self._clear_interaction_state() diff --git a/pyPhotoAlbum/mixins/interaction_validators.py b/pyPhotoAlbum/mixins/interaction_validators.py index d13163c..2bfe66b 100644 --- a/pyPhotoAlbum/mixins/interaction_validators.py +++ b/pyPhotoAlbum/mixins/interaction_validators.py @@ -16,6 +16,7 @@ def significant_change(threshold: float = 0.1): Returns: None if change is insignificant, otherwise returns the command builder result """ + def decorator(func): @wraps(func) def wrapper(*args, **kwargs): @@ -23,7 +24,9 @@ def significant_change(threshold: float = 0.1): if result is None: return None return result + return wrapper + return decorator @@ -31,9 +34,9 @@ class ChangeValidator: """Validates whether changes are significant enough to create commands.""" @staticmethod - def position_changed(old_pos: Optional[Tuple[float, float]], - new_pos: Optional[Tuple[float, float]], - threshold: float = 0.1) -> bool: + def position_changed( + old_pos: Optional[Tuple[float, float]], new_pos: Optional[Tuple[float, float]], threshold: float = 0.1 + ) -> bool: """Check if position changed significantly.""" if old_pos is None or new_pos is None: return False @@ -43,9 +46,9 @@ class ChangeValidator: return dx > threshold or dy > threshold @staticmethod - def size_changed(old_size: Optional[Tuple[float, float]], - new_size: Optional[Tuple[float, float]], - threshold: float = 0.1) -> bool: + def size_changed( + old_size: Optional[Tuple[float, float]], new_size: Optional[Tuple[float, float]], threshold: float = 0.1 + ) -> bool: """Check if size changed significantly.""" if old_size is None or new_size is None: return False @@ -55,9 +58,7 @@ class ChangeValidator: return dw > threshold or dh > threshold @staticmethod - def rotation_changed(old_rotation: Optional[float], - new_rotation: Optional[float], - threshold: float = 0.1) -> bool: + def rotation_changed(old_rotation: Optional[float], new_rotation: Optional[float], threshold: float = 0.1) -> bool: """Check if rotation changed significantly.""" if old_rotation is None or new_rotation is None: return False @@ -65,9 +66,11 @@ class ChangeValidator: return abs(new_rotation - old_rotation) > threshold @staticmethod - def crop_changed(old_crop: Optional[Tuple[float, float, float, float]], - new_crop: Optional[Tuple[float, float, float, float]], - threshold: float = 0.001) -> bool: + def crop_changed( + old_crop: Optional[Tuple[float, float, float, float]], + new_crop: Optional[Tuple[float, float, float, float]], + threshold: float = 0.001, + ) -> bool: """Check if crop info changed significantly.""" if old_crop is None or new_crop is None: return False @@ -85,8 +88,7 @@ class InteractionChangeDetector: self.threshold = threshold self.validator = ChangeValidator() - def detect_position_change(self, old_pos: Tuple[float, float], - new_pos: Tuple[float, float]) -> Optional[dict]: + def detect_position_change(self, old_pos: Tuple[float, float], new_pos: Tuple[float, float]) -> Optional[dict]: """ Detect position change and return change info. @@ -97,14 +99,13 @@ class InteractionChangeDetector: return None return { - 'old_position': old_pos, - 'new_position': new_pos, - 'delta_x': new_pos[0] - old_pos[0], - 'delta_y': new_pos[1] - old_pos[1] + "old_position": old_pos, + "new_position": new_pos, + "delta_x": new_pos[0] - old_pos[0], + "delta_y": new_pos[1] - old_pos[1], } - def detect_size_change(self, old_size: Tuple[float, float], - new_size: Tuple[float, float]) -> Optional[dict]: + def detect_size_change(self, old_size: Tuple[float, float], new_size: Tuple[float, float]) -> Optional[dict]: """ Detect size change and return change info. @@ -115,14 +116,13 @@ class InteractionChangeDetector: return None return { - 'old_size': old_size, - 'new_size': new_size, - 'delta_width': new_size[0] - old_size[0], - 'delta_height': new_size[1] - old_size[1] + "old_size": old_size, + "new_size": new_size, + "delta_width": new_size[0] - old_size[0], + "delta_height": new_size[1] - old_size[1], } - def detect_rotation_change(self, old_rotation: float, - new_rotation: float) -> Optional[dict]: + def detect_rotation_change(self, old_rotation: float, new_rotation: float) -> Optional[dict]: """ Detect rotation change and return change info. @@ -132,14 +132,11 @@ class InteractionChangeDetector: if not self.validator.rotation_changed(old_rotation, new_rotation, self.threshold): return None - return { - 'old_rotation': old_rotation, - 'new_rotation': new_rotation, - 'delta_angle': new_rotation - old_rotation - } + return {"old_rotation": old_rotation, "new_rotation": new_rotation, "delta_angle": new_rotation - old_rotation} - def detect_crop_change(self, old_crop: Tuple[float, float, float, float], - new_crop: Tuple[float, float, float, float]) -> Optional[dict]: + def detect_crop_change( + self, old_crop: Tuple[float, float, float, float], new_crop: Tuple[float, float, float, float] + ) -> Optional[dict]: """ Detect crop change and return change info. @@ -149,8 +146,4 @@ class InteractionChangeDetector: if not self.validator.crop_changed(old_crop, new_crop, threshold=0.001): return None - return { - 'old_crop': old_crop, - 'new_crop': new_crop, - 'delta': tuple(new_crop[i] - old_crop[i] for i in range(4)) - } + return {"old_crop": old_crop, "new_crop": new_crop, "delta": tuple(new_crop[i] - old_crop[i] for i in range(4))} diff --git a/pyPhotoAlbum/mixins/keyboard_navigation.py b/pyPhotoAlbum/mixins/keyboard_navigation.py index 848b6e0..4366b5a 100644 --- a/pyPhotoAlbum/mixins/keyboard_navigation.py +++ b/pyPhotoAlbum/mixins/keyboard_navigation.py @@ -16,7 +16,7 @@ class KeyboardNavigationMixin: def _navigate_to_next_page(self): """Navigate to the next page using Page Down key""" main_window = self.window() - if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: + if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages: return current_index = main_window._get_most_visible_page_index() @@ -24,14 +24,14 @@ class KeyboardNavigationMixin: next_page = main_window.project.pages[current_index + 1] self._scroll_to_page(next_page, current_index + 1) - if hasattr(main_window, 'show_status'): + if hasattr(main_window, "show_status"): page_name = main_window.project.get_page_display_name(next_page) main_window.show_status(f"Navigated to {page_name}", 2000) def _navigate_to_previous_page(self): """Navigate to the previous page using Page Up key""" main_window = self.window() - if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: + if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages: return current_index = main_window._get_most_visible_page_index() @@ -39,7 +39,7 @@ class KeyboardNavigationMixin: prev_page = main_window.project.pages[current_index - 1] self._scroll_to_page(prev_page, current_index - 1) - if hasattr(main_window, 'show_status'): + if hasattr(main_window, "show_status"): page_name = main_window.project.get_page_display_name(prev_page) main_window.show_status(f"Navigated to {page_name}", 2000) @@ -52,7 +52,7 @@ class KeyboardNavigationMixin: page_index: The index of the page in the project """ main_window = self.window() - if not hasattr(main_window, 'project'): + if not hasattr(main_window, "project"): return dpi = main_window.project.working_dpi @@ -79,14 +79,14 @@ class KeyboardNavigationMixin: self.pan_offset[1] = target_pan_y # Clamp pan offset to content bounds - if hasattr(self, 'clamp_pan_offset'): + if hasattr(self, "clamp_pan_offset"): self.clamp_pan_offset() self.update() # Update scrollbars if available main_window = self.window() - if hasattr(main_window, 'update_scrollbars'): + if hasattr(main_window, "update_scrollbars"): main_window.update_scrollbars() def _move_viewport_with_arrow_keys(self, key): @@ -109,14 +109,14 @@ class KeyboardNavigationMixin: self.pan_offset[0] -= move_amount # Clamp pan offset to content bounds - if hasattr(self, 'clamp_pan_offset'): + if hasattr(self, "clamp_pan_offset"): self.clamp_pan_offset() self.update() # Update scrollbars if available main_window = self.window() - if hasattr(main_window, 'update_scrollbars'): + if hasattr(main_window, "update_scrollbars"): main_window.update_scrollbars() def _move_selected_elements_with_arrow_keys(self, key): @@ -127,7 +127,7 @@ class KeyboardNavigationMixin: key: The Qt key code (Up, Down, Left, Right) """ main_window = self.window() - if not hasattr(main_window, 'project'): + if not hasattr(main_window, "project"): return # Movement amount in mm @@ -151,7 +151,7 @@ class KeyboardNavigationMixin: new_y = current_y + dy # Apply snapping if element has a parent page - if hasattr(element, '_parent_page') and element._parent_page: + if hasattr(element, "_parent_page") and element._parent_page: page = element._parent_page snap_sys = page.layout.snapping_system page_size = page.layout.size @@ -162,7 +162,7 @@ class KeyboardNavigationMixin: size=element.size, page_size=page_size, dpi=dpi, - project=main_window.project + project=main_window.project, ) element.position = snapped_pos else: @@ -170,7 +170,7 @@ class KeyboardNavigationMixin: self.update() - if hasattr(main_window, 'show_status'): + if hasattr(main_window, "show_status"): count = len(self.selected_elements) elem_text = "element" if count == 1 else "elements" main_window.show_status(f"Moved {count} {elem_text}", 1000) diff --git a/pyPhotoAlbum/mixins/mouse_interaction.py b/pyPhotoAlbum/mixins/mouse_interaction.py index dc3128a..a8af0b8 100644 --- a/pyPhotoAlbum/mixins/mouse_interaction.py +++ b/pyPhotoAlbum/mixins/mouse_interaction.py @@ -66,7 +66,9 @@ class MouseInteractionMixin: element = self._get_element_at(x, y) if element: - print(f"DEBUG: Clicked on element: {element}, ctrl_pressed: {ctrl_pressed}, shift_pressed: {shift_pressed}") + print( + f"DEBUG: Clicked on element: {element}, ctrl_pressed: {ctrl_pressed}, shift_pressed: {shift_pressed}" + ) # Check if Ctrl is pressed and element is ImageData - enter image pan mode if ctrl_pressed and isinstance(element, ImageData) and not self.rotation_mode: # Enter image pan mode - pan image within frame @@ -129,7 +131,7 @@ class MouseInteractionMixin: self.pan_offset[1] += dy # Clamp pan offset to content bounds - if hasattr(self, 'clamp_pan_offset'): + if hasattr(self, "clamp_pan_offset"): self.clamp_pan_offset() self.drag_start_pos = (x, y) @@ -137,7 +139,7 @@ class MouseInteractionMixin: # Update scrollbars if available main_window = self.window() - if hasattr(main_window, 'update_scrollbars'): + if hasattr(main_window, "update_scrollbars"): main_window.update_scrollbars() return @@ -152,7 +154,7 @@ class MouseInteractionMixin: elif self.rotation_mode: # Rotation mode - if not hasattr(self.selected_element, '_page_renderer'): + if not hasattr(self.selected_element, "_page_renderer"): return renderer = self.selected_element._page_renderer @@ -173,7 +175,7 @@ class MouseInteractionMixin: self.selected_element.rotation = angle main_window = self.window() - if hasattr(main_window, 'show_status'): + if hasattr(main_window, "show_status"): main_window.show_status(f"Rotation: {angle:.1f}°", 100) elif self.resize_handle: @@ -189,11 +191,13 @@ class MouseInteractionMixin: # Move mode current_page, current_page_index, current_renderer = self._get_page_at(x, y) - if current_page and hasattr(self.selected_element, '_parent_page'): + if current_page and hasattr(self.selected_element, "_parent_page"): source_page = self.selected_element._parent_page if current_page is not source_page: - self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer) + self._transfer_element_to_page( + self.selected_element, source_page, current_page, x, y, current_renderer + ) else: total_dx = (x - self.drag_start_pos[0]) / self.zoom_level total_dy = (y - self.drag_start_pos[1]) / self.zoom_level @@ -211,7 +215,7 @@ class MouseInteractionMixin: size=self.selected_element.size, page_size=page_size, dpi=dpi, - project=main_window.project + project=main_window.project, ) self.selected_element.position = snapped_pos @@ -238,11 +242,7 @@ class MouseInteractionMixin: self.rotation_start_angle = None self.image_pan_mode = False self.image_pan_start_crop = None - self.snap_state = { - 'is_snapped': False, - 'last_position': None, - 'last_size': None - } + self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None} self.setCursor(Qt.CursorShape.ArrowCursor) elif event.button() == Qt.MouseButton.MiddleButton: @@ -257,6 +257,7 @@ class MouseInteractionMixin: element = self._get_element_at(x, y) from pyPhotoAlbum.models import TextBoxData + if isinstance(element, TextBoxData): self._edit_text_element(element) return @@ -293,23 +294,20 @@ class MouseInteractionMixin: if self.is_dragging and self.drag_start_pos: pan_delta_x = self.pan_offset[0] - old_pan_x pan_delta_y = self.pan_offset[1] - old_pan_y - self.drag_start_pos = ( - self.drag_start_pos[0] + pan_delta_x, - self.drag_start_pos[1] + pan_delta_y - ) + self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y) # Clamp pan offset to content bounds - if hasattr(self, 'clamp_pan_offset'): + if hasattr(self, "clamp_pan_offset"): self.clamp_pan_offset() self.update() main_window = self.window() - if hasattr(main_window, 'status_bar'): + if hasattr(main_window, "status_bar"): main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000) # Update scrollbars if available - if hasattr(main_window, 'update_scrollbars'): + if hasattr(main_window, "update_scrollbars"): main_window.update_scrollbars() else: # Regular wheel: Vertical scroll @@ -318,22 +316,19 @@ class MouseInteractionMixin: self.pan_offset[1] += scroll_amount # Clamp pan offset to content bounds - if hasattr(self, 'clamp_pan_offset'): + if hasattr(self, "clamp_pan_offset"): self.clamp_pan_offset() # If dragging, adjust drag_start_pos to account for pan_offset change if self.is_dragging and self.drag_start_pos: pan_delta_y = self.pan_offset[1] - old_pan_y - self.drag_start_pos = ( - self.drag_start_pos[0], - self.drag_start_pos[1] + pan_delta_y - ) + self.drag_start_pos = (self.drag_start_pos[0], self.drag_start_pos[1] + pan_delta_y) self.update() # Update scrollbars if available main_window = self.window() - if hasattr(main_window, 'update_scrollbars'): + if hasattr(main_window, "update_scrollbars"): main_window.update_scrollbars() def _edit_text_element(self, text_element): @@ -344,9 +339,9 @@ class MouseInteractionMixin: if dialog.exec() == TextEditDialog.DialogCode.Accepted: values = dialog.get_values() - text_element.text_content = values['text_content'] - text_element.font_settings = values['font_settings'] - text_element.alignment = values['alignment'] + text_element.text_content = values["text_content"] + text_element.font_settings = values["font_settings"] + text_element.alignment = values["alignment"] self.update() diff --git a/pyPhotoAlbum/mixins/operations/__init__.py b/pyPhotoAlbum/mixins/operations/__init__.py index 479a7a7..b597bd1 100644 --- a/pyPhotoAlbum/mixins/operations/__init__.py +++ b/pyPhotoAlbum/mixins/operations/__init__.py @@ -15,15 +15,15 @@ from pyPhotoAlbum.mixins.operations.zorder_ops import ZOrderOperationsMixin from pyPhotoAlbum.mixins.operations.merge_ops import MergeOperationsMixin __all__ = [ - 'FileOperationsMixin', - 'EditOperationsMixin', - 'ElementOperationsMixin', - 'PageOperationsMixin', - 'TemplateOperationsMixin', - 'ViewOperationsMixin', - 'AlignmentOperationsMixin', - 'DistributionOperationsMixin', - 'SizeOperationsMixin', - 'ZOrderOperationsMixin', - 'MergeOperationsMixin', + "FileOperationsMixin", + "EditOperationsMixin", + "ElementOperationsMixin", + "PageOperationsMixin", + "TemplateOperationsMixin", + "ViewOperationsMixin", + "AlignmentOperationsMixin", + "DistributionOperationsMixin", + "SizeOperationsMixin", + "ZOrderOperationsMixin", + "MergeOperationsMixin", ] diff --git a/pyPhotoAlbum/mixins/operations/alignment_ops.py b/pyPhotoAlbum/mixins/operations/alignment_ops.py index 5d0a6ca..ce4c744 100644 --- a/pyPhotoAlbum/mixins/operations/alignment_ops.py +++ b/pyPhotoAlbum/mixins/operations/alignment_ops.py @@ -9,130 +9,130 @@ from pyPhotoAlbum.commands import AlignElementsCommand, ResizeElementsCommand class AlignmentOperationsMixin: """Mixin providing element alignment operations""" - + def _get_selected_elements_list(self): """Get list of selected elements for alignment operations""" return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else [] - + @ribbon_action( label="Align Left", tooltip="Align selected elements to the left", tab="Arrange", group="Align", requires_selection=True, - min_selection=2 + min_selection=2, ) def align_left(self): """Align selected elements to the left""" elements = self._get_selected_elements_list() if not self.require_selection(min_count=2): return - + changes = AlignmentManager.align_left(elements) if changes: cmd = AlignElementsCommand(changes) self.project.history.execute(cmd) self.update_view() self.show_status(f"Aligned {len(elements)} elements to left", 2000) - + @ribbon_action( label="Align Right", tooltip="Align selected elements to the right", tab="Arrange", group="Align", requires_selection=True, - min_selection=2 + min_selection=2, ) def align_right(self): """Align selected elements to the right""" elements = self._get_selected_elements_list() if not self.require_selection(min_count=2): return - + changes = AlignmentManager.align_right(elements) if changes: cmd = AlignElementsCommand(changes) self.project.history.execute(cmd) self.update_view() self.show_status(f"Aligned {len(elements)} elements to right", 2000) - + @ribbon_action( label="Align Top", tooltip="Align selected elements to the top", tab="Arrange", group="Align", requires_selection=True, - min_selection=2 + min_selection=2, ) def align_top(self): """Align selected elements to the top""" elements = self._get_selected_elements_list() if not self.require_selection(min_count=2): return - + changes = AlignmentManager.align_top(elements) if changes: cmd = AlignElementsCommand(changes) self.project.history.execute(cmd) self.update_view() self.show_status(f"Aligned {len(elements)} elements to top", 2000) - + @ribbon_action( label="Align Bottom", tooltip="Align selected elements to the bottom", tab="Arrange", group="Align", requires_selection=True, - min_selection=2 + min_selection=2, ) def align_bottom(self): """Align selected elements to the bottom""" elements = self._get_selected_elements_list() if not self.require_selection(min_count=2): return - + changes = AlignmentManager.align_bottom(elements) if changes: cmd = AlignElementsCommand(changes) self.project.history.execute(cmd) self.update_view() self.show_status(f"Aligned {len(elements)} elements to bottom", 2000) - + @ribbon_action( label="Align H-Center", tooltip="Align selected elements to horizontal center", tab="Arrange", group="Align", requires_selection=True, - min_selection=2 + min_selection=2, ) def align_horizontal_center(self): """Align selected elements to horizontal center""" elements = self._get_selected_elements_list() if not self.require_selection(min_count=2): return - + changes = AlignmentManager.align_horizontal_center(elements) if changes: cmd = AlignElementsCommand(changes) self.project.history.execute(cmd) self.update_view() self.show_status(f"Aligned {len(elements)} elements to horizontal center", 2000) - + @ribbon_action( label="Align V-Center", tooltip="Align selected elements to vertical center", tab="Arrange", group="Align", requires_selection=True, - min_selection=2 + min_selection=2, ) def align_vertical_center(self): """Align selected elements to vertical center""" elements = self._get_selected_elements_list() if not self.require_selection(min_count=2): return - + changes = AlignmentManager.align_vertical_center(elements) if changes: cmd = AlignElementsCommand(changes) @@ -146,7 +146,7 @@ class AlignmentOperationsMixin: tab="Arrange", group="Size", requires_selection=True, - min_selection=1 + min_selection=1, ) def maximize_pattern(self): """Maximize selected elements until they are close to borders or each other""" diff --git a/pyPhotoAlbum/mixins/operations/distribution_ops.py b/pyPhotoAlbum/mixins/operations/distribution_ops.py index b1f377c..7a07da2 100644 --- a/pyPhotoAlbum/mixins/operations/distribution_ops.py +++ b/pyPhotoAlbum/mixins/operations/distribution_ops.py @@ -9,88 +9,88 @@ from pyPhotoAlbum.commands import AlignElementsCommand class DistributionOperationsMixin: """Mixin providing element distribution and spacing operations""" - + def _get_selected_elements_list(self): """Get list of selected elements for distribution operations""" return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else [] - + @ribbon_action( label="Distribute H", tooltip="Distribute selected elements evenly horizontally", tab="Arrange", group="Distribute", requires_selection=True, - min_selection=3 + min_selection=3, ) def distribute_horizontally(self): """Distribute selected elements evenly horizontally""" elements = self._get_selected_elements_list() if not self.require_selection(min_count=3): return - + changes = AlignmentManager.distribute_horizontally(elements) if changes: cmd = AlignElementsCommand(changes) self.project.history.execute(cmd) self.update_view() self.show_status(f"Distributed {len(elements)} elements horizontally", 2000) - + @ribbon_action( label="Distribute V", tooltip="Distribute selected elements evenly vertically", tab="Arrange", group="Distribute", requires_selection=True, - min_selection=3 + min_selection=3, ) def distribute_vertically(self): """Distribute selected elements evenly vertically""" elements = self._get_selected_elements_list() if not self.require_selection(min_count=3): return - + changes = AlignmentManager.distribute_vertically(elements) if changes: cmd = AlignElementsCommand(changes) self.project.history.execute(cmd) self.update_view() self.show_status(f"Distributed {len(elements)} elements vertically", 2000) - + @ribbon_action( label="Space H", tooltip="Space selected elements equally horizontally", tab="Arrange", group="Distribute", requires_selection=True, - min_selection=3 + min_selection=3, ) def space_horizontally(self): """Space selected elements equally horizontally""" elements = self._get_selected_elements_list() if not self.require_selection(min_count=3): return - + changes = AlignmentManager.space_horizontally(elements) if changes: cmd = AlignElementsCommand(changes) self.project.history.execute(cmd) self.update_view() self.show_status(f"Spaced {len(elements)} elements horizontally", 2000) - + @ribbon_action( label="Space V", tooltip="Space selected elements equally vertically", tab="Arrange", group="Distribute", requires_selection=True, - min_selection=3 + min_selection=3, ) def space_vertically(self): """Space selected elements equally vertically""" elements = self._get_selected_elements_list() if not self.require_selection(min_count=3): return - + changes = AlignmentManager.space_vertically(elements) if changes: cmd = AlignElementsCommand(changes) diff --git a/pyPhotoAlbum/mixins/operations/edit_ops.py b/pyPhotoAlbum/mixins/operations/edit_ops.py index fdfc920..6dfd3c6 100644 --- a/pyPhotoAlbum/mixins/operations/edit_ops.py +++ b/pyPhotoAlbum/mixins/operations/edit_ops.py @@ -8,14 +8,8 @@ from pyPhotoAlbum.commands import DeleteElementCommand, RotateElementCommand class EditOperationsMixin: """Mixin providing edit-related operations""" - - @ribbon_action( - label="Undo", - tooltip="Undo last action (Ctrl+Z)", - tab="Home", - group="Edit", - shortcut="Ctrl+Z" - ) + + @ribbon_action(label="Undo", tooltip="Undo last action (Ctrl+Z)", tab="Home", group="Edit", shortcut="Ctrl+Z") def undo(self): """Undo last action""" if self.project.history.undo(): @@ -25,13 +19,9 @@ class EditOperationsMixin: else: self.show_status("Nothing to undo", 2000) print("Nothing to undo") - + @ribbon_action( - label="Redo", - tooltip="Redo last action (Ctrl+Y or Ctrl+Shift+Z)", - tab="Home", - group="Edit", - shortcut="Ctrl+Y" + label="Redo", tooltip="Redo last action (Ctrl+Y or Ctrl+Shift+Z)", tab="Home", group="Edit", shortcut="Ctrl+Y" ) def redo(self): """Redo last action""" @@ -42,117 +32,113 @@ class EditOperationsMixin: else: self.show_status("Nothing to redo", 2000) print("Nothing to redo") - + @ribbon_action( label="Delete", tooltip="Delete selected element (Delete key)", tab="Home", group="Edit", shortcut="Delete", - requires_selection=True + requires_selection=True, ) def delete_selected_element(self): """Delete the currently selected element""" if not self.require_selection(min_count=1): return - + current_page = self.get_current_page() if not current_page: return - + # Delete the first selected element (for backward compatibility) # In the future, we could delete all selected elements selected_element = next(iter(self.gl_widget.selected_elements)) - + try: - cmd = DeleteElementCommand( - current_page.layout, - selected_element, - asset_manager=self.project.asset_manager - ) + cmd = DeleteElementCommand(current_page.layout, selected_element, asset_manager=self.project.asset_manager) self.project.history.execute(cmd) - + # Clear selection self.gl_widget.selected_elements.clear() - + # Update display self.update_view() - + self.show_status("Element deleted (Ctrl+Z to undo)", 2000) print("Deleted selected element") - + except Exception as e: self.show_error("Error", f"Failed to delete element: {str(e)}") print(f"Error deleting element: {e}") - + @ribbon_action( label="Rotate Left", tooltip="Rotate selected element 90° counter-clockwise", tab="Home", group="Transform", - requires_selection=True + requires_selection=True, ) def rotate_left(self): """Rotate selected element 90 degrees counter-clockwise""" if not self.require_selection(min_count=1): return - + selected_element = next(iter(self.gl_widget.selected_elements)) old_rotation = selected_element.rotation new_rotation = (old_rotation - 90) % 360 - + cmd = RotateElementCommand(selected_element, old_rotation, new_rotation) self.project.history.execute(cmd) self.update_view() self.show_status(f"Rotated left (Ctrl+Z to undo)", 2000) print(f"Rotated element left: {old_rotation}° → {new_rotation}°") - + @ribbon_action( label="Rotate Right", tooltip="Rotate selected element 90° clockwise", tab="Home", group="Transform", - requires_selection=True + requires_selection=True, ) def rotate_right(self): """Rotate selected element 90 degrees clockwise""" if not self.require_selection(min_count=1): return - + selected_element = next(iter(self.gl_widget.selected_elements)) old_rotation = selected_element.rotation new_rotation = (old_rotation + 90) % 360 - + cmd = RotateElementCommand(selected_element, old_rotation, new_rotation) self.project.history.execute(cmd) self.update_view() self.show_status(f"Rotated right (Ctrl+Z to undo)", 2000) print(f"Rotated element right: {old_rotation}° → {new_rotation}°") - + @ribbon_action( label="Reset Rotation", tooltip="Reset selected element rotation to 0°", tab="Home", group="Transform", - requires_selection=True + requires_selection=True, ) def reset_rotation(self): """Reset selected element rotation to 0 degrees""" if not self.require_selection(min_count=1): return - + selected_element = next(iter(self.gl_widget.selected_elements)) old_rotation = selected_element.rotation - + if old_rotation == 0: self.show_status("Element already at 0°", 2000) return - + cmd = RotateElementCommand(selected_element, old_rotation, 0) self.project.history.execute(cmd) - + self.update_view() self.show_status("Reset rotation to 0° (Ctrl+Z to undo)", 2000) print(f"Reset element rotation: {old_rotation}° → 0°") diff --git a/pyPhotoAlbum/mixins/operations/element_ops.py b/pyPhotoAlbum/mixins/operations/element_ops.py index f38622e..770a1fb 100644 --- a/pyPhotoAlbum/mixins/operations/element_ops.py +++ b/pyPhotoAlbum/mixins/operations/element_ops.py @@ -11,33 +11,26 @@ from pyPhotoAlbum.async_backend import get_image_dimensions class ElementOperationsMixin: """Mixin providing element creation and manipulation operations""" - + @ribbon_action( - label="Image", - tooltip="Add an image to the current page", - tab="Insert", - group="Media", - requires_page=True + label="Image", tooltip="Add an image to the current page", tab="Insert", group="Media", requires_page=True ) def add_image(self): """Add an image to the current page""" if not self.require_page(): return - + file_path, _ = QFileDialog.getOpenFileName( - self, - "Select Image", - "", - "Image Files (*.jpg *.jpeg *.png *.gif *.bmp *.tiff *.webp);;All Files (*)" + self, "Select Image", "", "Image Files (*.jpg *.jpeg *.png *.gif *.bmp *.tiff *.webp);;All Files (*)" ) - + if not file_path: return - + current_page = self.get_current_page() if not current_page: return - + try: # Import asset to project asset_path = self.project.asset_manager.import_asset(file_path) @@ -54,111 +47,87 @@ class ElementOperationsMixin: # Create image element at center of page page_width_mm = current_page.layout.size[0] page_height_mm = current_page.layout.size[1] - + # Center position x = (page_width_mm - img_width) / 2 y = (page_height_mm - img_height) / 2 - - new_image = ImageData( - image_path=asset_path, - x=x, - y=y, - width=img_width, - height=img_height - ) - + + new_image = ImageData(image_path=asset_path, x=x, y=y, width=img_width, height=img_height) + # Add element using command pattern for undo/redo - cmd = AddElementCommand( - current_page.layout, - new_image, - asset_manager=self.project.asset_manager - ) + cmd = AddElementCommand(current_page.layout, new_image, asset_manager=self.project.asset_manager) self.project.history.execute(cmd) - + self.update_view() self.show_status("Added image (Ctrl+Z to undo)", 2000) print(f"Added image to page {self.get_current_page_index() + 1}: {asset_path}") - + except Exception as e: self.show_error("Error", f"Failed to add image: {str(e)}") print(f"Error adding image: {e}") - + @ribbon_action( - label="Text", - tooltip="Add a text box to the current page", - tab="Insert", - group="Media", - requires_page=True + label="Text", tooltip="Add a text box to the current page", tab="Insert", group="Media", requires_page=True ) def add_text(self): """Add text to the current page""" if not self.require_page(): return - + current_page = self.get_current_page() if not current_page: return - + # Create text box element at center of page page_width_mm = current_page.layout.size[0] page_height_mm = current_page.layout.size[1] - + text_width = 200 text_height = 50 - + # Center position x = (page_width_mm - text_width) / 2 y = (page_height_mm - text_height) / 2 - - new_text = TextBoxData( - text_content="New Text", - x=x, - y=y, - width=text_width, - height=text_height - ) - + + new_text = TextBoxData(text_content="New Text", x=x, y=y, width=text_width, height=text_height) + current_page.layout.add_element(new_text) self.update_view() - + print(f"Added text box to page {self.get_current_page_index() + 1}") - + @ribbon_action( label="Placeholder", tooltip="Add a placeholder to the current page", tab="Insert", group="Media", - requires_page=True + requires_page=True, ) def add_placeholder(self): """Add a placeholder to the current page""" if not self.require_page(): return - + current_page = self.get_current_page() if not current_page: return - + # Create placeholder element at center of page page_width_mm = current_page.layout.size[0] page_height_mm = current_page.layout.size[1] - + placeholder_width = 200 placeholder_height = 150 - + # Center position x = (page_width_mm - placeholder_width) / 2 y = (page_height_mm - placeholder_height) / 2 - + new_placeholder = PlaceholderData( - placeholder_type="image", - x=x, - y=y, - width=placeholder_width, - height=placeholder_height + placeholder_type="image", x=x, y=y, width=placeholder_width, height=placeholder_height ) - + current_page.layout.add_element(new_placeholder) self.update_view() - + print(f"Added placeholder to page {self.get_current_page_index() + 1}") diff --git a/pyPhotoAlbum/mixins/operations/file_ops.py b/pyPhotoAlbum/mixins/operations/file_ops.py index 96bfcb8..7918558 100644 --- a/pyPhotoAlbum/mixins/operations/file_ops.py +++ b/pyPhotoAlbum/mixins/operations/file_ops.py @@ -5,9 +5,19 @@ File operations mixin for pyPhotoAlbum import os from PyQt6.QtWidgets import ( - QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel, - QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton, - QButtonGroup, QLineEdit, QTextEdit + QFileDialog, + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QDoubleSpinBox, + QSpinBox, + QPushButton, + QGroupBox, + QRadioButton, + QButtonGroup, + QLineEdit, + QTextEdit, ) from pyPhotoAlbum.decorators import ribbon_action, numerical_input from pyPhotoAlbum.project import Project, Page @@ -21,23 +31,17 @@ from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog class FileOperationsMixin: """Mixin providing file-related operations""" - - @ribbon_action( - label="New", - tooltip="Create a new project", - tab="Home", - group="File", - shortcut="Ctrl+N" - ) + + @ribbon_action(label="New", tooltip="Create a new project", tab="Home", group="File", shortcut="Ctrl+N") def new_project(self): """Create a new project with initial setup dialog""" # Create new project setup dialog dialog = QDialog(self) dialog.setWindowTitle("New Project Setup") dialog.setMinimumWidth(450) - + layout = QVBoxLayout() - + # Project name group name_group = QGroupBox("Project Name") name_layout = QVBoxLayout() @@ -47,16 +51,16 @@ class FileOperationsMixin: name_layout.addWidget(name_input) name_group.setLayout(name_layout) layout.addWidget(name_group) - + # Default page size group size_group = QGroupBox("Default Page Size") size_layout = QVBoxLayout() - + info_label = QLabel("This will be the default size for all new pages in this project.") info_label.setWordWrap(True) info_label.setStyleSheet("font-size: 9pt; color: gray;") size_layout.addWidget(info_label) - + # Width width_layout = QHBoxLayout() width_layout.addWidget(QLabel("Width:")) @@ -66,7 +70,7 @@ class FileOperationsMixin: width_spinbox.setSuffix(" mm") width_layout.addWidget(width_spinbox) size_layout.addLayout(width_layout) - + # Height height_layout = QHBoxLayout() height_layout.addWidget(QLabel("Height:")) @@ -76,37 +80,37 @@ class FileOperationsMixin: height_spinbox.setSuffix(" mm") height_layout.addWidget(height_spinbox) size_layout.addLayout(height_layout) - + # Add common size presets presets_layout = QHBoxLayout() presets_layout.addWidget(QLabel("Presets:")) - + def set_preset(w, h): width_spinbox.setValue(w) height_spinbox.setValue(h) - + preset_a4 = QPushButton("A4 (210×297)") preset_a4.clicked.connect(lambda: set_preset(210, 297)) presets_layout.addWidget(preset_a4) - + preset_a5 = QPushButton("A5 (148×210)") preset_a5.clicked.connect(lambda: set_preset(148, 210)) presets_layout.addWidget(preset_a5) - + preset_square = QPushButton("Square (200×200)") preset_square.clicked.connect(lambda: set_preset(200, 200)) presets_layout.addWidget(preset_square) - + presets_layout.addStretch() size_layout.addLayout(presets_layout) - + size_group.setLayout(size_layout) layout.addWidget(size_group) - + # DPI settings group dpi_group = QGroupBox("DPI Settings") dpi_layout = QVBoxLayout() - + # Working DPI working_dpi_layout = QHBoxLayout() working_dpi_layout.addWidget(QLabel("Working DPI:")) @@ -115,7 +119,7 @@ class FileOperationsMixin: working_dpi_spinbox.setValue(300) working_dpi_layout.addWidget(working_dpi_spinbox) dpi_layout.addLayout(working_dpi_layout) - + # Export DPI export_dpi_layout = QHBoxLayout() export_dpi_layout.addWidget(QLabel("Export DPI:")) @@ -124,10 +128,10 @@ class FileOperationsMixin: export_dpi_spinbox.setValue(300) export_dpi_layout.addWidget(export_dpi_spinbox) dpi_layout.addLayout(export_dpi_layout) - + dpi_group.setLayout(dpi_layout) layout.addWidget(dpi_group) - + # Buttons button_layout = QHBoxLayout() cancel_btn = QPushButton("Cancel") @@ -135,14 +139,14 @@ class FileOperationsMixin: create_btn = QPushButton("Create Project") create_btn.clicked.connect(dialog.accept) create_btn.setDefault(True) - + button_layout.addStretch() button_layout.addWidget(cancel_btn) button_layout.addWidget(create_btn) layout.addLayout(button_layout) - + dialog.setLayout(layout) - + # Show dialog if dialog.exec() == QDialog.DialogCode.Accepted: # Get values @@ -153,7 +157,7 @@ class FileOperationsMixin: export_dpi = export_dpi_spinbox.value() # Cleanup old project if it exists - if hasattr(self, 'project') and self.project: + if hasattr(self, "project") and self.project: self.project.cleanup() # Create project with custom settings @@ -167,34 +171,25 @@ class FileOperationsMixin: # Update view self.update_view() - + self.show_status(f"New project created: {project_name} ({width_mm}×{height_mm} mm)") print(f"New project created: {project_name}, default page size: {width_mm}×{height_mm} mm") else: # User cancelled - keep current project print("New project creation cancelled") - - @ribbon_action( - label="Open", - tooltip="Open an existing project", - tab="Home", - group="File", - shortcut="Ctrl+O" - ) + + @ribbon_action(label="Open", tooltip="Open an existing project", tab="Home", group="File", shortcut="Ctrl+O") def open_project(self): """Open an existing project with async loading and progress bar""" file_path, _ = QFileDialog.getOpenFileName( - self, - "Open Project", - "", - "pyPhotoAlbum Projects (*.ppz);;All Files (*)" + self, "Open Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)" ) if file_path: print(f"Opening project: {file_path}") # Create loading widget if not exists - if not hasattr(self, '_loading_widget'): + if not hasattr(self, "_loading_widget"): self._loading_widget = LoadingWidget(self) # Show loading widget @@ -214,29 +209,29 @@ class FileOperationsMixin: def _on_load_progress(self, current: int, total: int, message: str): """Handle loading progress updates""" - if hasattr(self, '_loading_widget'): + if hasattr(self, "_loading_widget"): self._loading_widget.set_progress(current, total) self._loading_widget.set_status(message) def _on_load_complete(self, project): """Handle successful project load""" # Cleanup old project if it exists - if hasattr(self, 'project') and self.project: + if hasattr(self, "project") and self.project: self.project.cleanup() # Set new project self.project = project # Set file path and mark as clean - if hasattr(self, '_opening_file_path'): + if hasattr(self, "_opening_file_path"): self.project.file_path = self._opening_file_path - delattr(self, '_opening_file_path') + delattr(self, "_opening_file_path") self.project.mark_clean() self.gl_widget.current_page_index = 0 # Reset to first page # Hide loading widget - if hasattr(self, '_loading_widget'): + if hasattr(self, "_loading_widget"): self._loading_widget.hide_loading() # Update view (this will trigger progressive image loading) @@ -254,32 +249,23 @@ class FileOperationsMixin: def _on_load_failed(self, error_msg: str): """Handle project load failure""" # Hide loading widget - if hasattr(self, '_loading_widget'): + if hasattr(self, "_loading_widget"): self._loading_widget.hide_loading() error_msg = f"Failed to open project: {error_msg}" self.show_status(error_msg) self.show_error("Load Failed", error_msg) print(error_msg) - - @ribbon_action( - label="Save", - tooltip="Save the current project", - tab="Home", - group="File", - shortcut="Ctrl+S" - ) + + @ribbon_action(label="Save", tooltip="Save the current project", tab="Home", group="File", shortcut="Ctrl+S") def save_project(self): """Save the current project""" # If project has a file path, use it; otherwise prompt for location - file_path = self.project.file_path if hasattr(self.project, 'file_path') and self.project.file_path else None + file_path = self.project.file_path if hasattr(self.project, "file_path") and self.project.file_path else None if not file_path: file_path, _ = QFileDialog.getSaveFileName( - self, - "Save Project", - "", - "pyPhotoAlbum Projects (*.ppz);;All Files (*)" + self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)" ) if file_path: @@ -298,12 +284,7 @@ class FileOperationsMixin: self.show_status(error_msg) print(error_msg) - @ribbon_action( - label="Heal Assets", - tooltip="Reconnect missing image assets", - tab="Home", - group="File" - ) + @ribbon_action(label="Heal Assets", tooltip="Reconnect missing image assets", tab="Home", group="File") def heal_assets(self): """Open the asset healing dialog to reconnect missing images""" dialog = AssetHealDialog(self.project, self) @@ -348,10 +329,12 @@ class FileOperationsMixin: msg.setWindowTitle("Missing Assets") msg.setText(f"{len(missing_assets)} image(s) could not be found in the assets folder:") msg.setInformativeText(asset_list) - msg.setDetailedText("These images need to be reconnected using the 'Heal Assets' feature.\n\n" - "Go to: Home → Heal Assets\n\n" - "Add search paths where the original images might be located, " - "then click 'Attempt Healing' to find and import them.") + msg.setDetailedText( + "These images need to be reconnected using the 'Heal Assets' feature.\n\n" + "Go to: Home → Heal Assets\n\n" + "Add search paths where the original images might be located, " + "then click 'Attempt Healing' to find and import them." + ) msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open) msg.button(QMessageBox.StandardButton.Open).setText("Open Heal Assets") @@ -360,30 +343,22 @@ class FileOperationsMixin: self.heal_assets() @ribbon_action( - label="Project Settings", - tooltip="Configure project-wide page size and defaults", - tab="Home", - group="File" - ) - @numerical_input( - fields=[ - ('width', 'Width', 'mm', 10, 1000), - ('height', 'Height', 'mm', 10, 1000) - ] + label="Project Settings", tooltip="Configure project-wide page size and defaults", tab="Home", group="File" ) + @numerical_input(fields=[("width", "Width", "mm", 10, 1000), ("height", "Height", "mm", 10, 1000)]) def project_settings(self): """Configure project-wide settings including default page size""" # Create dialog dialog = QDialog(self) dialog.setWindowTitle("Project Settings") dialog.setMinimumWidth(500) - + layout = QVBoxLayout() - + # Page size group size_group = QGroupBox("Default Page Size") size_layout = QVBoxLayout() - + # Width width_layout = QHBoxLayout() width_layout.addWidget(QLabel("Width:")) @@ -393,7 +368,7 @@ class FileOperationsMixin: width_spinbox.setSuffix(" mm") width_layout.addWidget(width_spinbox) size_layout.addLayout(width_layout) - + # Height height_layout = QHBoxLayout() height_layout.addWidget(QLabel("Height:")) @@ -403,14 +378,14 @@ class FileOperationsMixin: height_spinbox.setSuffix(" mm") height_layout.addWidget(height_spinbox) size_layout.addLayout(height_layout) - + size_group.setLayout(size_layout) layout.addWidget(size_group) - + # DPI settings group dpi_group = QGroupBox("DPI Settings") dpi_layout = QVBoxLayout() - + # Working DPI working_dpi_layout = QHBoxLayout() working_dpi_layout.addWidget(QLabel("Working DPI:")) @@ -419,7 +394,7 @@ class FileOperationsMixin: working_dpi_spinbox.setValue(self.project.working_dpi) working_dpi_layout.addWidget(working_dpi_spinbox) dpi_layout.addLayout(working_dpi_layout) - + # Export DPI export_dpi_layout = QHBoxLayout() export_dpi_layout.addWidget(QLabel("Export DPI:")) @@ -428,48 +403,50 @@ class FileOperationsMixin: export_dpi_spinbox.setValue(self.project.export_dpi) export_dpi_layout.addWidget(export_dpi_spinbox) dpi_layout.addLayout(export_dpi_layout) - + dpi_group.setLayout(dpi_layout) layout.addWidget(dpi_group) - + # Content scaling options (only if pages exist and size is changing) scaling_group = None scaling_buttons = None - + if self.project.pages: scaling_group = QGroupBox("Apply to Existing Pages") scaling_layout = QVBoxLayout() - - info_label = QLabel("How should existing content be adjusted?\n(Pages with manual sizing will not be affected)") + + info_label = QLabel( + "How should existing content be adjusted?\n(Pages with manual sizing will not be affected)" + ) info_label.setWordWrap(True) scaling_layout.addWidget(info_label) - + scaling_buttons = QButtonGroup() - + proportional_radio = QRadioButton("Resize proportionally (fit to smallest axis)") proportional_radio.setToolTip("Scale content uniformly to fit the new page size") scaling_buttons.addButton(proportional_radio, 0) scaling_layout.addWidget(proportional_radio) - + stretch_radio = QRadioButton("Resize on both axes (stretch)") stretch_radio.setToolTip("Scale width and height independently") scaling_buttons.addButton(stretch_radio, 1) scaling_layout.addWidget(stretch_radio) - + reposition_radio = QRadioButton("Keep content size, reposition to center") reposition_radio.setToolTip("Maintain element sizes but center them on new page") scaling_buttons.addButton(reposition_radio, 2) scaling_layout.addWidget(reposition_radio) - + none_radio = QRadioButton("Don't adjust content (page size only)") none_radio.setToolTip("Only change page size, leave content as-is") none_radio.setChecked(True) # Default scaling_buttons.addButton(none_radio, 3) scaling_layout.addWidget(none_radio) - + scaling_group.setLayout(scaling_layout) layout.addWidget(scaling_group) - + # Buttons button_layout = QHBoxLayout() cancel_btn = QPushButton("Cancel") @@ -477,14 +454,14 @@ class FileOperationsMixin: ok_btn = QPushButton("OK") ok_btn.clicked.connect(dialog.accept) ok_btn.setDefault(True) - + button_layout.addStretch() button_layout.addWidget(cancel_btn) button_layout.addWidget(ok_btn) layout.addLayout(button_layout) - + dialog.setLayout(layout) - + # Show dialog if dialog.exec() == QDialog.DialogCode.Accepted: # Get new values @@ -492,32 +469,32 @@ class FileOperationsMixin: new_height = height_spinbox.value() new_working_dpi = working_dpi_spinbox.value() new_export_dpi = export_dpi_spinbox.value() - + # Determine scaling mode - scaling_mode = 'none' + scaling_mode = "none" if scaling_buttons: selected_id = scaling_buttons.checkedId() - modes = {0: 'proportional', 1: 'stretch', 2: 'reposition', 3: 'none'} - scaling_mode = modes.get(selected_id, 'none') - + modes = {0: "proportional", 1: "stretch", 2: "reposition", 3: "none"} + scaling_mode = modes.get(selected_id, "none") + # Apply settings old_size = self.project.page_size_mm self.project.page_size_mm = (new_width, new_height) self.project.working_dpi = new_working_dpi self.project.export_dpi = new_export_dpi - + # Update existing pages (exclude manually sized ones) if self.project.pages and old_size != (new_width, new_height): self._apply_page_size_to_project(old_size, (new_width, new_height), scaling_mode) - + self.update_view() self.show_status(f"Project settings updated: {new_width}×{new_height} mm", 2000) print(f"Project settings updated: {new_width}×{new_height} mm, scaling mode: {scaling_mode}") - + def _apply_page_size_to_project(self, old_size, new_size, scaling_mode): """ Apply new page size to all non-manually-sized pages - + Args: old_size: Old page size (width, height) in mm new_size: New page size (width, height) in mm @@ -525,41 +502,41 @@ class FileOperationsMixin: """ old_width, old_height = old_size new_width, new_height = new_size - + width_ratio = new_width / old_width if old_width > 0 else 1.0 height_ratio = new_height / old_height if old_height > 0 else 1.0 - + for page in self.project.pages: # Skip manually sized pages if page.manually_sized: continue - + # Update page size old_page_width, old_page_height = page.layout.size - + # For double spreads, maintain the 2x multiplier if page.is_double_spread: page.layout.size = (new_width * 2, new_height) else: page.layout.size = (new_width, new_height) - + # Apply content scaling based on mode - if scaling_mode == 'proportional': + if scaling_mode == "proportional": # Use smallest ratio to fit content scale = min(width_ratio, height_ratio) self._scale_page_elements(page, scale, scale) - elif scaling_mode == 'stretch': + elif scaling_mode == "stretch": # Scale independently on each axis self._scale_page_elements(page, width_ratio, height_ratio) - elif scaling_mode == 'reposition': + elif scaling_mode == "reposition": # Keep size, center content self._reposition_page_elements(page, old_size, new_size) # 'none' - do nothing to elements - + def _scale_page_elements(self, page, x_scale, y_scale): """ Scale all elements on a page - + Args: page: Page object x_scale: Horizontal scale factor @@ -569,15 +546,15 @@ class FileOperationsMixin: # Scale position x, y = element.position element.position = (x * x_scale, y * y_scale) - + # Scale size width, height = element.size element.size = (width * x_scale, height * y_scale) - + def _reposition_page_elements(self, page, old_size, new_size): """ Reposition elements to center them on the new page size - + Args: page: Page object old_size: Old page size (width, height) in mm @@ -585,20 +562,15 @@ class FileOperationsMixin: """ old_width, old_height = old_size new_width, new_height = new_size - + x_offset = (new_width - old_width) / 2.0 y_offset = (new_height - old_height) / 2.0 - + for element in page.layout.elements: x, y = element.position element.position = (x + x_offset, y + y_offset) - - @ribbon_action( - label="Export PDF", - tooltip="Export project to PDF", - tab="Export", - group="Export" - ) + + @ribbon_action(label="Export PDF", tooltip="Export project to PDF", tab="Export", group="Export") def export_pdf(self): """Export project to PDF using async backend (non-blocking)""" # Check if we have pages to export @@ -607,19 +579,14 @@ class FileOperationsMixin: return # Show file save dialog - file_path, _ = QFileDialog.getSaveFileName( - self, - "Export to PDF", - "", - "PDF Files (*.pdf);;All Files (*)" - ) + file_path, _ = QFileDialog.getSaveFileName(self, "Export to PDF", "", "PDF Files (*.pdf);;All Files (*)") if not file_path: return # Ensure .pdf extension - if not file_path.lower().endswith('.pdf'): - file_path += '.pdf' + if not file_path.lower().endswith(".pdf"): + file_path += ".pdf" # Use async PDF export (non-blocking, UI stays responsive) success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=300) @@ -628,12 +595,7 @@ class FileOperationsMixin: else: self.show_status("PDF export failed to start", 3000) - @ribbon_action( - label="About", - tooltip="About pyPhotoAlbum and data format version", - tab="Home", - group="File" - ) + @ribbon_action(label="About", tooltip="About pyPhotoAlbum and data format version", tab="Home", group="File") def show_about(self): """Show about dialog with version information""" dialog = QDialog(self) diff --git a/pyPhotoAlbum/mixins/operations/merge_ops.py b/pyPhotoAlbum/mixins/operations/merge_ops.py index 1b44faf..5029b59 100644 --- a/pyPhotoAlbum/mixins/operations/merge_ops.py +++ b/pyPhotoAlbum/mixins/operations/merge_ops.py @@ -20,7 +20,7 @@ class MergeOperationsMixin: label="Merge Projects", tooltip="Merge another project file with the current project", tab="File", - group="Import/Export" + group="Import/Export", ) def merge_projects(self): """ @@ -35,22 +35,19 @@ class MergeOperationsMixin: self, "Unsaved Changes", "You have unsaved changes in the current project. Save before merging?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel, ) if reply == QMessageBox.StandardButton.Cancel: return elif reply == QMessageBox.StandardButton.Yes: # Save current project first - if hasattr(self, 'save_project'): + if hasattr(self, "save_project"): self.save_project() # Select file to merge file_path, _ = QFileDialog.getOpenFileName( - self, - "Select Project to Merge", - "", - "Photo Album Projects (*.ppz);;All Files (*)" + self, "Select Project to Merge", "", "Photo Album Projects (*.ppz);;All Files (*)" ) if not file_path: @@ -58,7 +55,7 @@ class MergeOperationsMixin: try: # Disable autosave during merge - if hasattr(self, '_autosave_timer'): + if hasattr(self, "_autosave_timer"): self._autosave_timer.stop() # Load the other project @@ -82,14 +79,10 @@ class MergeOperationsMixin: self._perform_concatenation(our_data, their_data) except Exception as e: - QMessageBox.critical( - self, - "Merge Error", - f"Failed to merge projects:\n{str(e)}" - ) + QMessageBox.critical(self, "Merge Error", f"Failed to merge projects:\n{str(e)}") finally: # Re-enable autosave - if hasattr(self, '_autosave_timer'): + if hasattr(self, "_autosave_timer"): self._autosave_timer.start() def _perform_merge_with_conflicts(self, our_data, their_data): @@ -104,7 +97,7 @@ class MergeOperationsMixin: self, "No Conflicts", "No conflicts detected. Merge projects automatically?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: @@ -117,11 +110,7 @@ class MergeOperationsMixin: dialog = MergeDialog(our_data, their_data, self) if dialog.exec() != QMessageBox.DialogCode.Accepted: - QMessageBox.information( - self, - "Merge Cancelled", - "Merge operation cancelled." - ) + QMessageBox.information(self, "Merge Cancelled", "Merge operation cancelled.") return # Get merged data from dialog @@ -135,7 +124,7 @@ class MergeOperationsMixin: "Merge Complete", f"Projects merged successfully.\n" f"Total pages: {len(merged_data.get('pages', []))}\n" - f"Resolved conflicts: {len(conflicts)}" + f"Resolved conflicts: {len(conflicts)}", ) def _perform_concatenation(self, our_data, their_data): @@ -147,7 +136,7 @@ class MergeOperationsMixin: f" • {our_data.get('name', 'Untitled')}\n" f" • {their_data.get('name', 'Untitled')}\n\n" f"Concatenate them (combine all pages)?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: @@ -162,8 +151,7 @@ class MergeOperationsMixin: QMessageBox.information( self, "Concatenation Complete", - f"Projects concatenated successfully.\n" - f"Total pages: {len(merged_data.get('pages', []))}" + f"Projects concatenated successfully.\n" f"Total pages: {len(merged_data.get('pages', []))}", ) def _apply_merged_data(self, merged_data): @@ -182,9 +170,9 @@ class MergeOperationsMixin: new_project.mark_dirty() # Update UI - if hasattr(self, 'gl_widget'): + if hasattr(self, "gl_widget"): self.gl_widget.set_project(new_project) self.gl_widget.update() - if hasattr(self, 'status_bar'): + if hasattr(self, "status_bar"): self.status_bar.showMessage("Merge completed successfully", 3000) diff --git a/pyPhotoAlbum/mixins/operations/page_ops.py b/pyPhotoAlbum/mixins/operations/page_ops.py index 9c22bfb..be0ad97 100644 --- a/pyPhotoAlbum/mixins/operations/page_ops.py +++ b/pyPhotoAlbum/mixins/operations/page_ops.py @@ -10,16 +10,11 @@ from pyPhotoAlbum.page_layout import PageLayout class PageOperationsMixin: """Mixin providing page management operations""" - + # Note: Previous/Next page navigation removed - now using scrollable multi-page view # User can scroll through all pages vertically - - @ribbon_action( - label="Add Page", - tooltip="Add a new page to the project", - tab="Layout", - group="Page" - ) + + @ribbon_action(label="Add Page", tooltip="Add a new page to the project", tab="Layout", group="Page") def add_page(self): """Add a new page to the project after the current page""" # Get the most visible page in viewport to determine insertion point @@ -72,13 +67,8 @@ class PageOperationsMixin: # Get display name for status message new_page_name = self.project.get_page_display_name(new_page) print(f"Added {new_page_name} at position {insert_index + 1} with size {width_mm}×{height_mm} mm") - - @ribbon_action( - label="Page Setup", - tooltip="Configure page size and settings", - tab="Layout", - group="Page" - ) + + @ribbon_action(label="Page Setup", tooltip="Configure page size and settings", tab="Layout", group="Page") @dialog_action(dialog_class=PageSetupDialog, requires_pages=True) def page_setup(self, values): """ @@ -90,17 +80,17 @@ class PageOperationsMixin: Args: values: Dictionary of values from the dialog """ - selected_page = values['selected_page'] - selected_index = values['selected_index'] + selected_page = values["selected_page"] + selected_index = values["selected_index"] # Update project cover settings - self.project.paper_thickness_mm = values['paper_thickness_mm'] - self.project.cover_bleed_mm = values['cover_bleed_mm'] + self.project.paper_thickness_mm = values["paper_thickness_mm"] + self.project.cover_bleed_mm = values["cover_bleed_mm"] # Handle cover designation (only for first page) if selected_index == 0: was_cover = selected_page.is_cover - is_cover = values['is_cover'] + is_cover = values["is_cover"] if was_cover != is_cover: selected_page.is_cover = is_cover @@ -116,8 +106,8 @@ class PageOperationsMixin: print(f"Cover removed from page 1") # Get new values - width_mm = values['width_mm'] - height_mm = values['height_mm'] + width_mm = values["width_mm"] + height_mm = values["height_mm"] # Don't allow manual size changes for covers if not selected_page.is_cover: @@ -126,11 +116,11 @@ class PageOperationsMixin: if selected_page.is_double_spread: old_base_width = ( selected_page.layout.base_width - if hasattr(selected_page.layout, 'base_width') + if hasattr(selected_page.layout, "base_width") else selected_page.layout.size[0] / 2 ) old_height = selected_page.layout.size[1] - size_changed = (old_base_width != width_mm or old_height != height_mm) + size_changed = old_base_width != width_mm or old_height != height_mm if size_changed: # Update double spread @@ -143,7 +133,7 @@ class PageOperationsMixin: ) else: old_size = selected_page.layout.size - size_changed = (old_size != (width_mm, height_mm)) + size_changed = old_size != (width_mm, height_mm) if size_changed: # Update single page @@ -151,16 +141,15 @@ class PageOperationsMixin: selected_page.layout.base_width = width_mm selected_page.manually_sized = True print( - f"{self.project.get_page_display_name(selected_page)} " - f"updated to {width_mm}×{height_mm} mm" + f"{self.project.get_page_display_name(selected_page)} " f"updated to {width_mm}×{height_mm} mm" ) # Update DPI settings - self.project.working_dpi = values['working_dpi'] - self.project.export_dpi = values['export_dpi'] + self.project.working_dpi = values["working_dpi"] + self.project.export_dpi = values["export_dpi"] # Set as default if checkbox is checked - if values['set_as_default']: + if values["set_as_default"]: self.project.page_size_mm = (width_mm, height_mm) print(f"Project default page size set to {width_mm}×{height_mm} mm") @@ -172,15 +161,12 @@ class PageOperationsMixin: status_msg = f"{page_name} updated" else: status_msg = f"{page_name} size: {width_mm}×{height_mm} mm" - if values['set_as_default']: + if values["set_as_default"]: status_msg += " (set as default)" self.show_status(status_msg, 2000) @ribbon_action( - label="Toggle Spread", - tooltip="Toggle double page spread for current page", - tab="Layout", - group="Page" + label="Toggle Spread", tooltip="Toggle double page spread for current page", tab="Layout", group="Page" ) def toggle_double_spread(self): """Toggle double spread for the current page""" @@ -195,31 +181,31 @@ class PageOperationsMixin: page_index = 0 current_page = self.project.pages[page_index] - + # Toggle the state is_double = not current_page.is_double_spread current_page.is_double_spread = is_double - + # Mark as manually sized when toggling spread current_page.manually_sized = True - + # Update the page layout width current_width = current_page.layout.size[0] current_height = current_page.layout.size[1] - + # Get base width (might already be doubled) - if hasattr(current_page.layout, 'base_width'): + if hasattr(current_page.layout, "base_width"): base_width = current_page.layout.base_width else: # Assume current width is single if not marked as facing base_width = current_width / 2 if current_page.layout.is_facing_page else current_width - + # Set new width based on double spread state new_width = base_width * 2 if is_double else base_width current_page.layout.base_width = base_width current_page.layout.is_facing_page = is_double current_page.layout.size = (new_width, current_height) - + # Update display self.update_view() @@ -227,13 +213,8 @@ class PageOperationsMixin: page_name = self.project.get_page_display_name(current_page) self.show_status(f"{page_name}: Double spread {status}, width = {new_width:.0f}mm", 2000) print(f"{page_name}: Double spread {status}, width = {new_width}mm") - - @ribbon_action( - label="Remove Page", - tooltip="Remove the currently selected page", - tab="Layout", - group="Page" - ) + + @ribbon_action(label="Remove Page", tooltip="Remove the currently selected page", tab="Layout", group="Page") def remove_page(self): """Remove the currently selected page""" if len(self.project.pages) <= 1: diff --git a/pyPhotoAlbum/mixins/operations/size_ops.py b/pyPhotoAlbum/mixins/operations/size_ops.py index 7c48cc5..39bf4e6 100644 --- a/pyPhotoAlbum/mixins/operations/size_ops.py +++ b/pyPhotoAlbum/mixins/operations/size_ops.py @@ -9,60 +9,60 @@ from pyPhotoAlbum.commands import ResizeElementsCommand class SizeOperationsMixin: """Mixin providing element sizing operations""" - + def _get_selected_elements_list(self): """Get list of selected elements for size operations""" return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else [] - + @ribbon_action( label="Same Size", tooltip="Make all selected elements the same size", tab="Arrange", group="Size", requires_selection=True, - min_selection=2 + min_selection=2, ) def make_same_size(self): """Make all selected elements the same size""" elements = self._get_selected_elements_list() if not self.require_selection(min_count=2): return - + changes = AlignmentManager.make_same_size(elements) if changes: cmd = ResizeElementsCommand(changes) self.project.history.execute(cmd) self.update_view() self.show_status(f"Resized {len(elements)} elements to same size", 2000) - + @ribbon_action( label="Same Width", tooltip="Make all selected elements the same width", tab="Arrange", group="Size", requires_selection=True, - min_selection=2 + min_selection=2, ) def make_same_width(self): """Make all selected elements the same width""" elements = self._get_selected_elements_list() if not self.require_selection(min_count=2): return - + changes = AlignmentManager.make_same_width(elements) if changes: cmd = ResizeElementsCommand(changes) self.project.history.execute(cmd) self.update_view() self.show_status(f"Resized {len(elements)} elements to same width", 2000) - + @ribbon_action( label="Same Height", tooltip="Make all selected elements the same height", tab="Arrange", group="Size", requires_selection=True, - min_selection=2 + min_selection=2, ) def make_same_height(self): """Make all selected elements the same height""" @@ -83,7 +83,7 @@ class SizeOperationsMixin: tab="Arrange", group="Size", requires_selection=True, - min_selection=1 + min_selection=1, ) def fit_to_width(self): """Fit selected element to page width""" @@ -114,7 +114,7 @@ class SizeOperationsMixin: tab="Arrange", group="Size", requires_selection=True, - min_selection=1 + min_selection=1, ) def fit_to_height(self): """Fit selected element to page height""" @@ -145,7 +145,7 @@ class SizeOperationsMixin: tab="Arrange", group="Size", requires_selection=True, - min_selection=1 + min_selection=1, ) def fit_to_page(self): """Fit selected element to page dimensions""" @@ -176,7 +176,7 @@ class SizeOperationsMixin: tab="Arrange", group="Size", requires_selection=True, - min_selection=1 + min_selection=1, ) def expand_image(self): """Expand selected image to fill available space""" @@ -195,16 +195,11 @@ class SizeOperationsMixin: other_elements = [e for e in page.layout.elements if e is not element] # Use configurable min_gap (grid spacing from snapping system, default 10mm) - min_gap = getattr(page.layout.snapping_system, 'grid_spacing', 10.0) + min_gap = getattr(page.layout.snapping_system, "grid_spacing", 10.0) # Expand to bounds page_width, page_height = page.layout.size - change = AlignmentManager.expand_to_bounds( - element, - (page_width, page_height), - other_elements, - min_gap - ) + change = AlignmentManager.expand_to_bounds(element, (page_width, page_height), other_elements, min_gap) if change: cmd = ResizeElementsCommand([change]) diff --git a/pyPhotoAlbum/mixins/operations/template_ops.py b/pyPhotoAlbum/mixins/operations/template_ops.py index 4e9bc54..e7e38f6 100644 --- a/pyPhotoAlbum/mixins/operations/template_ops.py +++ b/pyPhotoAlbum/mixins/operations/template_ops.py @@ -3,82 +3,75 @@ Template operations mixin for pyPhotoAlbum """ from PyQt6.QtWidgets import ( - QInputDialog, QDialog, QVBoxLayout, QLabel, QComboBox, - QRadioButton, QButtonGroup, QPushButton, QHBoxLayout, - QDoubleSpinBox + QInputDialog, + QDialog, + QVBoxLayout, + QLabel, + QComboBox, + QRadioButton, + QButtonGroup, + QPushButton, + QHBoxLayout, + QDoubleSpinBox, ) from pyPhotoAlbum.decorators import ribbon_action, undoable_operation class TemplateOperationsMixin: """Mixin providing template-related operations""" - + @ribbon_action( label="Save as Template", tooltip="Save current page as a reusable template", tab="Layout", group="Templates", - requires_page=True + requires_page=True, ) def save_page_as_template(self): """Save current page as a template""" current_page = self.get_current_page() if not current_page: return - + # Check if page has any elements if not current_page.layout.elements: self.show_warning("Empty Page", "Cannot save an empty page as a template.") return - + # Ask for template name name, ok = QInputDialog.getText( self, "Save Template", "Enter template name:", - text=f"Template_{len(self.template_manager.list_templates()) + 1}" + text=f"Template_{len(self.template_manager.list_templates()) + 1}", ) - + if not ok or not name: return - + # Ask for optional description - description, ok = QInputDialog.getText( - self, - "Template Description", - "Enter description (optional):" - ) - + description, ok = QInputDialog.getText(self, "Template Description", "Enter description (optional):") + if not ok: description = "" - + try: # Create template from page - template = self.template_manager.create_template_from_page( - current_page, - name, - description - ) - + template = self.template_manager.create_template_from_page(current_page, name, description) + # Save template self.template_manager.save_template(template) - - self.show_info( - "Template Saved", - f"Template '{name}' has been saved successfully." - ) - + + self.show_info("Template Saved", f"Template '{name}' has been saved successfully.") + print(f"Saved template: {name}") - + except Exception as e: self.show_error("Error", f"Failed to save template: {str(e)}") print(f"Error saving template: {e}") - + @ribbon_action( - label="New from Template", - tooltip="Create a new page from a template", - tab="Layout", - group="Templates" + label="New from Template", tooltip="Create a new page from a template", tab="Layout", group="Templates" ) def new_page_from_template(self): """Create a new page from a template""" @@ -87,8 +80,7 @@ class TemplateOperationsMixin: if not templates: self.show_info( - "No Templates", - "No templates available. Create a template first by using 'Save as Template'." + "No Templates", "No templates available. Create a template first by using 'Save as Template'." ) return @@ -178,7 +170,7 @@ class TemplateOperationsMixin: page_number=new_page_number, target_size_mm=self.project.page_size_mm, scale_mode=scale_mode, - margin_percent=margin_percent + margin_percent=margin_percent, ) # Add to project @@ -194,63 +186,62 @@ class TemplateOperationsMixin: except Exception as e: self.show_error("Error", f"Failed to create page from template: {str(e)}") print(f"Error creating page from template: {e}") - + @ribbon_action( label="Apply Template", tooltip="Apply a template layout to current page", tab="Layout", group="Templates", - requires_page=True + requires_page=True, ) - @undoable_operation(capture='page_elements', description='Apply Template') + @undoable_operation(capture="page_elements", description="Apply Template") def apply_template_to_page(self): """Apply a template to the current page""" current_page = self.get_current_page() if not current_page: return - + # Get available templates templates = self.template_manager.list_templates() - + if not templates: self.show_info( - "No Templates", - "No templates available. Create a template first by using 'Save as Template'." + "No Templates", "No templates available. Create a template first by using 'Save as Template'." ) return - + # Create dialog for template application options dialog = QDialog(self) dialog.setWindowTitle("Apply Template") dialog.setMinimumWidth(400) - + layout = QVBoxLayout() - + # Template selection layout.addWidget(QLabel("Select Template:")) template_combo = QComboBox() template_combo.addItems(templates) layout.addWidget(template_combo) - + layout.addSpacing(10) - + # Mode selection layout.addWidget(QLabel("Mode:")) mode_group = QButtonGroup(dialog) - + replace_radio = QRadioButton("Replace with placeholders") replace_radio.setChecked(True) replace_radio.setToolTip("Clear page and add template placeholders") mode_group.addButton(replace_radio, 0) layout.addWidget(replace_radio) - + reflow_radio = QRadioButton("Reflow existing content") reflow_radio.setToolTip("Keep existing images and reposition to template slots") mode_group.addButton(reflow_radio, 1) layout.addWidget(reflow_radio) - + layout.addSpacing(10) - + # Margin/Spacing percentage layout.addWidget(QLabel("Margin/Spacing:")) margin_layout = QHBoxLayout() @@ -264,9 +255,9 @@ class TemplateOperationsMixin: margin_layout.addWidget(margin_spinbox) margin_layout.addStretch() layout.addLayout(margin_layout) - + layout.addSpacing(10) - + # Scaling selection layout.addWidget(QLabel("Scaling:")) scale_group = QButtonGroup(dialog) @@ -279,13 +270,13 @@ class TemplateOperationsMixin: stretch_radio.setChecked(True) scale_group.addButton(stretch_radio, 1) layout.addWidget(stretch_radio) - + center_radio = QRadioButton("Center (no scaling)") scale_group.addButton(center_radio, 2) layout.addWidget(center_radio) - + layout.addSpacing(20) - + # Buttons button_layout = QHBoxLayout() cancel_btn = QPushButton("Cancel") @@ -293,38 +284,34 @@ class TemplateOperationsMixin: apply_btn = QPushButton("Apply") apply_btn.clicked.connect(dialog.accept) apply_btn.setDefault(True) - + button_layout.addStretch() button_layout.addWidget(cancel_btn) button_layout.addWidget(apply_btn) layout.addLayout(button_layout) - + dialog.setLayout(layout) - + # Show dialog if dialog.exec() != QDialog.DialogCode.Accepted: return - + # Get selections template_name = template_combo.currentText() mode_id = mode_group.checkedId() scale_id = scale_group.checkedId() margin_percent = margin_spinbox.value() - + mode = "replace" if mode_id == 0 else "reflow" scale_mode = ["proportional", "stretch", "center"][scale_id] - + try: # Load template template = self.template_manager.load_template(template_name) # Apply template to page self.template_manager.apply_template_to_page( - template, - current_page, - mode=mode, - scale_mode=scale_mode, - margin_percent=margin_percent + template, current_page, mode=mode, scale_mode=scale_mode, margin_percent=margin_percent ) # Update display @@ -332,7 +319,7 @@ class TemplateOperationsMixin: self.show_status(f"Applied template '{template_name}' to current page", 3000) print(f"Applied template '{template_name}' with mode={mode}, scale_mode={scale_mode}") - + except Exception as e: self.show_error("Error", f"Failed to apply template: {str(e)}") print(f"Error applying template: {e}") diff --git a/pyPhotoAlbum/mixins/operations/view_ops.py b/pyPhotoAlbum/mixins/operations/view_ops.py index 25e3c17..23ea652 100644 --- a/pyPhotoAlbum/mixins/operations/view_ops.py +++ b/pyPhotoAlbum/mixins/operations/view_ops.py @@ -7,14 +7,8 @@ from pyPhotoAlbum.decorators import ribbon_action class ViewOperationsMixin: """Mixin providing view-related operations""" - - @ribbon_action( - label="Zoom In", - tooltip="Zoom in", - tab="View", - group="Zoom", - shortcut="Ctrl++" - ) + + @ribbon_action(label="Zoom In", tooltip="Zoom in", tab="View", group="Zoom", shortcut="Ctrl++") def zoom_in(self): """Zoom in""" self.gl_widget.zoom_level *= 1.2 @@ -22,14 +16,8 @@ class ViewOperationsMixin: self.gl_widget.zoom_level = 5.0 self.update_view() self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000) - - @ribbon_action( - label="Zoom Out", - tooltip="Zoom out", - tab="View", - group="Zoom", - shortcut="Ctrl+-" - ) + + @ribbon_action(label="Zoom Out", tooltip="Zoom out", tab="View", group="Zoom", shortcut="Ctrl+-") def zoom_out(self): """Zoom out""" self.gl_widget.zoom_level /= 1.2 @@ -37,47 +25,36 @@ class ViewOperationsMixin: self.gl_widget.zoom_level = 0.1 self.update_view() self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000) - - @ribbon_action( - label="Fit to Window", - tooltip="Fit page to window", - tab="View", - group="Zoom", - shortcut="Ctrl+0" - ) + + @ribbon_action(label="Fit to Window", tooltip="Fit page to window", tab="View", group="Zoom", shortcut="Ctrl+0") def zoom_fit(self): """Fit page to window""" if not self.project.pages: return - + current_page = self.project.pages[self.gl_widget.current_page_index] page_width_mm = current_page.layout.size[0] page_height_mm = current_page.layout.size[1] - + # Convert to pixels dpi = self.project.working_dpi page_width_px = page_width_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4 - + # Get widget size widget_width = self.gl_widget.width() - 100 # Margins widget_height = self.gl_widget.height() - 100 - + # Calculate zoom to fit zoom_w = widget_width / page_width_px zoom_h = widget_height / page_height_px - + self.gl_widget.zoom_level = min(zoom_w, zoom_h) self.gl_widget.zoom_level = max(0.1, min(5.0, self.gl_widget.zoom_level)) self.update_view() self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000) - - @ribbon_action( - label="Toggle Grid Snap", - tooltip="Toggle snapping to grid", - tab="View", - group="Snapping" - ) + + @ribbon_action(label="Toggle Grid Snap", tooltip="Toggle snapping to grid", tab="View", group="Snapping") def toggle_grid_snap(self): """Toggle grid snapping""" if not self.project: @@ -89,13 +66,8 @@ class ViewOperationsMixin: self.update_view() self.show_status(f"Grid snapping {status}", 2000) print(f"Grid snapping {status}") - - @ribbon_action( - label="Toggle Edge Snap", - tooltip="Toggle snapping to page edges", - tab="View", - group="Snapping" - ) + + @ribbon_action(label="Toggle Edge Snap", tooltip="Toggle snapping to page edges", tab="View", group="Snapping") def toggle_edge_snap(self): """Toggle edge snapping""" if not self.project: @@ -107,13 +79,8 @@ class ViewOperationsMixin: self.update_view() self.show_status(f"Edge snapping {status}", 2000) print(f"Edge snapping {status}") - - @ribbon_action( - label="Toggle Guide Snap", - tooltip="Toggle snapping to guides", - tab="View", - group="Snapping" - ) + + @ribbon_action(label="Toggle Guide Snap", tooltip="Toggle snapping to guides", tab="View", group="Snapping") def toggle_guide_snap(self): """Toggle guide snapping""" if not self.project: @@ -125,13 +92,8 @@ class ViewOperationsMixin: self.update_view() self.show_status(f"Guide snapping {status}", 2000) print(f"Guide snapping {status}") - - @ribbon_action( - label="Show Grid", - tooltip="Toggle visibility of grid lines", - tab="View", - group="Snapping" - ) + + @ribbon_action(label="Show Grid", tooltip="Toggle visibility of grid lines", tab="View", group="Snapping") def toggle_show_grid(self): """Toggle grid visibility""" if not self.project: @@ -144,12 +106,7 @@ class ViewOperationsMixin: self.show_status(f"Grid {status}", 2000) print(f"Grid {status}") - @ribbon_action( - label="Show Guides", - tooltip="Toggle visibility of guide lines", - tab="View", - group="Snapping" - ) + @ribbon_action(label="Show Guides", tooltip="Toggle visibility of guide lines", tab="View", group="Snapping") def toggle_snap_lines(self): """Toggle guide lines visibility""" if not self.project: @@ -161,71 +118,53 @@ class ViewOperationsMixin: self.update_view() self.show_status(f"Guides {status}", 2000) print(f"Guides {status}") - - @ribbon_action( - label="Add H Guide", - tooltip="Add horizontal guide at page center", - tab="View", - group="Guides" - ) + + @ribbon_action(label="Add H Guide", tooltip="Add horizontal guide at page center", tab="View", group="Guides") def add_horizontal_guide(self): """Add a horizontal guide at page center""" current_page = self.get_current_page() if not current_page: return - + # Add guide at vertical center (in mm) center_y = current_page.layout.size[1] / 2.0 - current_page.layout.snapping_system.add_guide(center_y, 'horizontal') - + current_page.layout.snapping_system.add_guide(center_y, "horizontal") + self.update_view() self.show_status(f"Added horizontal guide at {center_y:.1f} mm", 2000) print(f"Added horizontal guide at {center_y:.1f} mm") - - @ribbon_action( - label="Add V Guide", - tooltip="Add vertical guide at page center", - tab="View", - group="Guides" - ) + + @ribbon_action(label="Add V Guide", tooltip="Add vertical guide at page center", tab="View", group="Guides") def add_vertical_guide(self): """Add a vertical guide at page center""" current_page = self.get_current_page() if not current_page: return - + # Add guide at horizontal center (in mm) center_x = current_page.layout.size[0] / 2.0 - current_page.layout.snapping_system.add_guide(center_x, 'vertical') - + current_page.layout.snapping_system.add_guide(center_x, "vertical") + self.update_view() self.show_status(f"Added vertical guide at {center_x:.1f} mm", 2000) print(f"Added vertical guide at {center_x:.1f} mm") - - @ribbon_action( - label="Clear Guides", - tooltip="Clear all guides from current page", - tab="View", - group="Guides" - ) + + @ribbon_action(label="Clear Guides", tooltip="Clear all guides from current page", tab="View", group="Guides") def clear_guides(self): """Clear all guides from current page""" current_page = self.get_current_page() if not current_page: return - + guide_count = len(current_page.layout.snapping_system.guides) current_page.layout.snapping_system.clear_guides() - + self.update_view() self.show_status(f"Cleared {guide_count} guides", 2000) print(f"Cleared {guide_count} guides") - + @ribbon_action( - label="Set Grid Size...", - tooltip="Configure grid spacing for snapping", - tab="View", - group="Snapping" + label="Set Grid Size...", tooltip="Configure grid spacing for snapping", tab="View", group="Snapping" ) def set_grid_size(self): """Open dialog to set grid size""" @@ -295,67 +234,49 @@ class ViewOperationsMixin: self.update_view() self.show_status(f"Grid size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm", 2000) print(f"Updated grid settings - Size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm") - + # ===== Layout Tab Snapping Controls ===== # These provide easy access to snapping features during layout work - + @ribbon_action( label="Grid Snap", tooltip="Enable/disable snapping to grid (Ctrl+G)", tab="Layout", group="Snapping", - shortcut="Ctrl+G" + shortcut="Ctrl+G", ) def layout_toggle_grid_snap(self): """Toggle grid snapping (Layout tab)""" self.toggle_grid_snap() - + @ribbon_action( label="Edge Snap", tooltip="Enable/disable snapping to page edges (Ctrl+E)", tab="Layout", group="Snapping", - shortcut="Ctrl+E" + shortcut="Ctrl+E", ) def layout_toggle_edge_snap(self): """Toggle edge snapping (Layout tab)""" self.toggle_edge_snap() - - @ribbon_action( - label="Guide Snap", - tooltip="Enable/disable snapping to guides", - tab="Layout", - group="Snapping" - ) + + @ribbon_action(label="Guide Snap", tooltip="Enable/disable snapping to guides", tab="Layout", group="Snapping") def layout_toggle_guide_snap(self): """Toggle guide snapping (Layout tab)""" self.toggle_guide_snap() - - @ribbon_action( - label="Show Grid", - tooltip="Toggle visibility of grid lines", - tab="Layout", - group="Snapping" - ) + + @ribbon_action(label="Show Grid", tooltip="Toggle visibility of grid lines", tab="Layout", group="Snapping") def layout_toggle_show_grid(self): """Toggle grid visibility (Layout tab)""" self.toggle_show_grid() - @ribbon_action( - label="Show Guides", - tooltip="Toggle visibility of guide lines", - tab="Layout", - group="Snapping" - ) + @ribbon_action(label="Show Guides", tooltip="Toggle visibility of guide lines", tab="Layout", group="Snapping") def layout_toggle_snap_lines(self): """Toggle guide lines visibility (Layout tab)""" self.toggle_snap_lines() - + @ribbon_action( - label="Grid Settings...", - tooltip="Configure grid size and snap threshold", - tab="Layout", - group="Snapping" + label="Grid Settings...", tooltip="Configure grid size and snap threshold", tab="Layout", group="Snapping" ) def layout_set_grid_size(self): """Open grid settings dialog (Layout tab)""" diff --git a/pyPhotoAlbum/mixins/operations/zorder_ops.py b/pyPhotoAlbum/mixins/operations/zorder_ops.py index 10c0d3c..e2117d3 100644 --- a/pyPhotoAlbum/mixins/operations/zorder_ops.py +++ b/pyPhotoAlbum/mixins/operations/zorder_ops.py @@ -8,159 +8,159 @@ from pyPhotoAlbum.commands import ChangeZOrderCommand class ZOrderOperationsMixin: """Mixin providing z-order/layer control operations""" - + @ribbon_action( label="Bring to Front", tooltip="Bring selected element to front", tab="Arrange", group="Order", shortcut="Ctrl+Shift+]", - requires_selection=True + requires_selection=True, ) def bring_to_front(self): """Bring selected element to front (end of list)""" if not self.gl_widget.selected_element: return - + current_page = self.get_current_page() if not current_page: return - + element = self.gl_widget.selected_element elements = current_page.layout.elements - + if element not in elements: return - + old_index = elements.index(element) new_index = len(elements) - 1 - + if old_index == new_index: self.show_status("Element is already at front", 2000) return - + # Create and execute command cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index) self.project.history.execute(cmd) - + self.update_view() self.show_status("Brought element to front (Ctrl+Z to undo)", 2000) print(f"Brought element to front: {old_index} → {new_index}") - + @ribbon_action( label="Send to Back", tooltip="Send selected element to back", tab="Arrange", group="Order", shortcut="Ctrl+Shift+[", - requires_selection=True + requires_selection=True, ) def send_to_back(self): """Send selected element to back (start of list)""" if not self.gl_widget.selected_element: return - + current_page = self.get_current_page() if not current_page: return - + element = self.gl_widget.selected_element elements = current_page.layout.elements - + if element not in elements: return - + old_index = elements.index(element) new_index = 0 - + if old_index == new_index: self.show_status("Element is already at back", 2000) return - + # Create and execute command cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index) self.project.history.execute(cmd) - + self.update_view() self.show_status("Sent element to back (Ctrl+Z to undo)", 2000) print(f"Sent element to back: {old_index} → {new_index}") - + @ribbon_action( label="Bring Forward", tooltip="Bring selected element forward one layer", tab="Arrange", group="Order", shortcut="Ctrl+]", - requires_selection=True + requires_selection=True, ) def bring_forward(self): """Move selected element forward one position in list""" if not self.gl_widget.selected_element: return - + current_page = self.get_current_page() if not current_page: return - + element = self.gl_widget.selected_element elements = current_page.layout.elements - + if element not in elements: return - + old_index = elements.index(element) new_index = old_index + 1 - + if new_index >= len(elements): self.show_status("Element is already at front", 2000) return - + # Create and execute command cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index) self.project.history.execute(cmd) - + self.update_view() self.show_status("Brought element forward (Ctrl+Z to undo)", 2000) print(f"Brought element forward: {old_index} → {new_index}") - + @ribbon_action( label="Send Backward", tooltip="Send selected element backward one layer", tab="Arrange", group="Order", shortcut="Ctrl+[", - requires_selection=True + requires_selection=True, ) def send_backward(self): """Move selected element backward one position in list""" if not self.gl_widget.selected_element: return - + current_page = self.get_current_page() if not current_page: return - + element = self.gl_widget.selected_element elements = current_page.layout.elements - + if element not in elements: return - + old_index = elements.index(element) new_index = old_index - 1 - + if new_index < 0: self.show_status("Element is already at back", 2000) return - + # Create and execute command cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index) self.project.history.execute(cmd) - + self.update_view() self.show_status("Sent element backward (Ctrl+Z to undo)", 2000) print(f"Sent element backward: {old_index} → {new_index}") - + @ribbon_action( label="Swap Order", tooltip="Swap z-order of two selected elements", @@ -168,21 +168,21 @@ class ZOrderOperationsMixin: group="Order", shortcut="Ctrl+Shift+X", requires_selection=True, - min_selection=2 + min_selection=2, ) def swap_order(self): """Swap the z-order of two selected elements""" if len(self.gl_widget.selected_elements) != 2: self.show_status("Please select exactly 2 elements to swap", 2000) return - + current_page = self.get_current_page() if not current_page: return - + elements = current_page.layout.elements selected = list(self.gl_widget.selected_elements) - + # Get indices of both elements try: index1 = elements.index(selected[0]) @@ -190,10 +190,10 @@ class ZOrderOperationsMixin: except ValueError: self.show_status("Selected elements not found on current page", 2000) return - + # Swap them in the list elements[index1], elements[index2] = elements[index2], elements[index1] - + self.update_view() self.show_status(f"Swapped z-order of elements", 2000) print(f"Swapped elements at indices {index1} and {index2}") diff --git a/pyPhotoAlbum/mixins/page_navigation.py b/pyPhotoAlbum/mixins/page_navigation.py index b97abec..5e10d44 100644 --- a/pyPhotoAlbum/mixins/page_navigation.py +++ b/pyPhotoAlbum/mixins/page_navigation.py @@ -33,11 +33,11 @@ class PageNavigationMixin: Returns: Tuple of (page, page_index, renderer) or (None, -1, None) if no page at coordinates """ - if not hasattr(self, '_page_renderers') or not self._page_renderers: + if not hasattr(self, "_page_renderers") or not self._page_renderers: return None, -1, None main_window = self.window() - if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: + if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages: return None, -1, None # Check each page to find which one contains the coordinates @@ -57,7 +57,7 @@ class PageNavigationMixin: List of tuples (page_type, page_or_ghost_data, y_offset) """ main_window = self.window() - if not hasattr(main_window, 'project'): + if not hasattr(main_window, "project"): return [] dpi = main_window.project.working_dpi @@ -77,7 +77,7 @@ class PageNavigationMixin: # First, render cover if it exists for page in main_window.project.pages: if page.is_cover: - result.append(('page', page, current_y)) + result.append(("page", page, current_y)) # Calculate cover height in pixels page_height_mm = page.layout.size[1] @@ -91,7 +91,7 @@ class PageNavigationMixin: layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts() for page_type, page_obj, logical_pos in layout_with_ghosts: - if page_type == 'page': + if page_type == "page": # Regular page (single or double spread) result.append((page_type, page_obj, current_y)) @@ -103,7 +103,7 @@ class PageNavigationMixin: # Move to next position (add height + spacing) current_y += page_height_px + spacing_px - elif page_type == 'ghost': + elif page_type == "ghost": # Ghost page - use default page size page_size_mm = main_window.project.page_size_mm from pyPhotoAlbum.models import GhostPageData @@ -131,11 +131,11 @@ class PageNavigationMixin: Returns: bool: True if a ghost page was clicked and a new page was created """ - if not hasattr(self, '_page_renderers'): + if not hasattr(self, "_page_renderers"): return False main_window = self.window() - if not hasattr(main_window, 'project'): + if not hasattr(main_window, "project"): return False # Get page positions which includes ghosts @@ -144,7 +144,7 @@ class PageNavigationMixin: # Check each position for ghost pages for idx, (page_type, page_or_ghost, y_offset) in enumerate(page_positions): # Skip non-ghost pages - if page_type != 'ghost': + if page_type != "ghost": continue ghost = page_or_ghost @@ -156,20 +156,21 @@ class PageNavigationMixin: screen_y = (y_offset * self.zoom_level) + self.pan_offset[1] from pyPhotoAlbum.page_renderer import PageRenderer + renderer = PageRenderer( page_width_mm=ghost_width_mm, page_height_mm=ghost_height_mm, screen_x=screen_x, screen_y=screen_y, dpi=dpi, - zoom=self.zoom_level + zoom=self.zoom_level, ) # Check if click is anywhere on the ghost page (entire page is clickable) if renderer.is_point_in_page(x, y): # User clicked the ghost page! # Calculate the insertion index (count real pages before this ghost in page_positions) - insert_index = sum(1 for i, (pt, _, _) in enumerate(page_positions) if i < idx and pt == 'page') + insert_index = sum(1 for i, (pt, _, _) in enumerate(page_positions) if i < idx and pt == "page") print(f"Ghost page clicked at index {insert_index} - inserting new page in place") @@ -181,10 +182,9 @@ class PageNavigationMixin: new_page_number = insert_index + 1 new_page = Page( layout=PageLayout( - width=main_window.project.page_size_mm[0], - height=main_window.project.page_size_mm[1] + width=main_window.project.page_size_mm[0], height=main_window.project.page_size_mm[1] ), - page_number=new_page_number + page_number=new_page_number, ) # Insert the page at the correct position @@ -209,10 +209,10 @@ class PageNavigationMixin: y: Screen Y coordinate """ main_window = self.window() - if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: + if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages: return - if not hasattr(self, '_page_renderers') or not self._page_renderers: + if not hasattr(self, "_page_renderers") or not self._page_renderers: return # Get total page count (accounting for double spreads = 2 pages each) @@ -228,7 +228,7 @@ class PageNavigationMixin: if page.is_double_spread: side = renderer.get_sub_page_at(x, is_facing_page=True) page_nums = page.get_page_numbers() - if side == 'left': + if side == "left": current_page_info = f"Page {page_nums[0]}" else: current_page_info = f"Page {page_nums[1]}" @@ -237,8 +237,10 @@ class PageNavigationMixin: break # Update status bar - if hasattr(main_window, 'status_bar'): + if hasattr(main_window, "status_bar"): if current_page_info: - main_window.status_bar.showMessage(f"{current_page_info} of {total_pages} | Zoom: {int(self.zoom_level * 100)}%") + main_window.status_bar.showMessage( + f"{current_page_info} of {total_pages} | Zoom: {int(self.zoom_level * 100)}%" + ) else: main_window.status_bar.showMessage(f"Total pages: {total_pages} | Zoom: {int(self.zoom_level * 100)}%") diff --git a/pyPhotoAlbum/mixins/rendering.py b/pyPhotoAlbum/mixins/rendering.py index 184c2ea..7fa6ff2 100644 --- a/pyPhotoAlbum/mixins/rendering.py +++ b/pyPhotoAlbum/mixins/rendering.py @@ -26,7 +26,7 @@ class RenderingMixin: glLoadIdentity() main_window = self.window() - if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: + if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages: return # Set initial zoom and center the page if not done yet @@ -37,7 +37,7 @@ class RenderingMixin: # Update scrollbars now that we have content bounds main_window = self.window() - if hasattr(main_window, 'update_scrollbars'): + if hasattr(main_window, "update_scrollbars"): main_window.update_scrollbars() dpi = main_window.project.working_dpi @@ -55,7 +55,7 @@ class RenderingMixin: for page_info in page_positions: page_type, page_or_ghost, y_offset = page_info - if page_type == 'page': + if page_type == "page": page = page_or_ghost page_width_mm, page_height_mm = page.layout.size @@ -68,7 +68,7 @@ class RenderingMixin: screen_x=screen_x, screen_y=screen_y, dpi=dpi, - zoom=self.zoom_level + zoom=self.zoom_level, ) self._page_renderers.append((renderer, page)) @@ -79,7 +79,7 @@ class RenderingMixin: page.layout.render(dpi=dpi, project=main_window.project) renderer.end_render() - elif page_type == 'ghost': + elif page_type == "ghost": ghost = page_or_ghost ghost_width_mm, ghost_height_mm = ghost.page_size @@ -92,14 +92,14 @@ class RenderingMixin: screen_x=screen_x, screen_y=screen_y, dpi=dpi, - zoom=self.zoom_level + zoom=self.zoom_level, ) self._render_ghost_page(ghost, renderer) # Update PageRenderer references for selected elements for element in self.selected_elements: - if hasattr(element, '_parent_page'): + if hasattr(element, "_parent_page"): for renderer, page in self._page_renderers: if page is element._parent_page: element._page_renderer = renderer @@ -118,10 +118,10 @@ class RenderingMixin: return main_window = self.window() - if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: + if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages: return - if not hasattr(element, '_page_renderer'): + if not hasattr(element, "_page_renderer"): return renderer = element._page_renderer @@ -188,10 +188,10 @@ class RenderingMixin: glEnd() else: handles = [ - (x - handle_size/2, y - handle_size/2), - (x + w - handle_size/2, y - handle_size/2), - (x - handle_size/2, y + h - handle_size/2), - (x + w - handle_size/2, y + h - handle_size/2), + (x - handle_size / 2, y - handle_size / 2), + (x + w - handle_size / 2, y - handle_size / 2), + (x - handle_size / 2, y + h - handle_size / 2), + (x + w - handle_size / 2, y + h - handle_size / 2), ] glColor3f(1.0, 1.0, 1.0) @@ -214,7 +214,7 @@ class RenderingMixin: def _render_text_overlays(self): """Render text content for TextBoxData elements using QPainter overlay""" - if not hasattr(self, '_page_renderers') or not self._page_renderers: + if not hasattr(self, "_page_renderers") or not self._page_renderers: return painter = QPainter(self) @@ -236,12 +236,12 @@ class RenderingMixin: screen_w = w * renderer.zoom screen_h = h * renderer.zoom - font_family = element.font_settings.get('family', 'Arial') - font_size = int(element.font_settings.get('size', 12) * renderer.zoom) + font_family = element.font_settings.get("family", "Arial") + font_size = int(element.font_settings.get("size", 12) * renderer.zoom) font = QFont(font_family, font_size) painter.setFont(font) - font_color = element.font_settings.get('color', (0, 0, 0)) + font_color = element.font_settings.get("color", (0, 0, 0)) if all(isinstance(c, int) and c > 1 for c in font_color): color = QColor(*font_color) else: @@ -260,9 +260,9 @@ class RenderingMixin: rect = QRectF(screen_x, screen_y, screen_w, screen_h) alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop - if element.alignment == 'center': + if element.alignment == "center": alignment = Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop - elif element.alignment == 'right': + elif element.alignment == "right": alignment = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop text_flags = Qt.TextFlag.TextWordWrap diff --git a/pyPhotoAlbum/mixins/viewport.py b/pyPhotoAlbum/mixins/viewport.py index 99d44e7..80bb88d 100644 --- a/pyPhotoAlbum/mixins/viewport.py +++ b/pyPhotoAlbum/mixins/viewport.py @@ -61,7 +61,7 @@ class ViewportMixin: # Update scrollbars when viewport size changes main_window = self.window() - if hasattr(main_window, 'update_scrollbars'): + if hasattr(main_window, "update_scrollbars"): main_window.update_scrollbars() def _calculate_fit_to_screen_zoom(self): @@ -72,7 +72,7 @@ class ViewportMixin: float: Zoom level (1.0 = 100%, 0.5 = 50%, etc.) """ main_window = self.window() - if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: + if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages: return 1.0 window_width = self.width() @@ -106,7 +106,7 @@ class ViewportMixin: list: [x_offset, y_offset] to center the page """ main_window = self.window() - if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: + if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages: return [0, 0] window_width = self.width() @@ -141,8 +141,8 @@ class ViewportMixin: dict: {'min_x', 'max_x', 'min_y', 'max_y', 'width', 'height'} in pixels """ main_window = self.window() - if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: - return {'min_x': 0, 'max_x': 800, 'min_y': 0, 'max_y': 600, 'width': 800, 'height': 600} + if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages: + return {"min_x": 0, "max_x": 800, "min_y": 0, "max_y": 600, "width": 800, "height": 600} dpi = main_window.project.working_dpi PAGE_MARGIN = 50 @@ -167,12 +167,12 @@ class ViewportMixin: total_height += PAGE_MARGIN return { - 'min_x': 0, - 'max_x': total_width, - 'min_y': 0, - 'max_y': total_height, - 'width': total_width, - 'height': total_height + "min_x": 0, + "max_x": total_width, + "min_y": 0, + "max_y": total_height, + "width": total_width, + "height": total_height, } def clamp_pan_offset(self): @@ -188,7 +188,7 @@ class ViewportMixin: when zooming on pages of different widths. """ main_window = self.window() - if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: + if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages: return viewport_width = self.width() @@ -200,7 +200,7 @@ class ViewportMixin: # Vertical clamping bounds = self.get_content_bounds() - content_height = bounds['height'] + content_height = bounds["height"] # Save original pan_offset[1] BEFORE clamping for page selection # This prevents clamping from changing which page we think we're on diff --git a/pyPhotoAlbum/models.py b/pyPhotoAlbum/models.py index b5acf21..7dbb23f 100644 --- a/pyPhotoAlbum/models.py +++ b/pyPhotoAlbum/models.py @@ -13,13 +13,39 @@ from PIL import Image from pyPhotoAlbum.image_utils import apply_pil_rotation, calculate_center_crop_coords from pyPhotoAlbum.gl_imports import ( - GL_AVAILABLE, glBegin, glEnd, glVertex2f, glColor3f, glColor4f, - GL_QUADS, GL_LINE_LOOP, glEnable, glDisable, GL_TEXTURE_2D, - glBindTexture, glTexCoord2f, glTexParameteri, GL_TEXTURE_MIN_FILTER, - GL_TEXTURE_MAG_FILTER, GL_LINEAR, glGenTextures, glTexImage2D, - GL_RGBA, GL_UNSIGNED_BYTE, glDeleteTextures, glGetString, GL_VERSION, - glLineStipple, GL_LINE_STIPPLE, glPushMatrix, glPopMatrix, - glTranslatef, glRotatef, GL_BLEND, glBlendFunc, GL_SRC_ALPHA, + GL_AVAILABLE, + glBegin, + glEnd, + glVertex2f, + glColor3f, + glColor4f, + GL_QUADS, + GL_LINE_LOOP, + glEnable, + glDisable, + GL_TEXTURE_2D, + glBindTexture, + glTexCoord2f, + glTexParameteri, + GL_TEXTURE_MIN_FILTER, + GL_TEXTURE_MAG_FILTER, + GL_LINEAR, + glGenTextures, + glTexImage2D, + GL_RGBA, + GL_UNSIGNED_BYTE, + glDeleteTextures, + glGetString, + GL_VERSION, + glLineStipple, + GL_LINE_STIPPLE, + glPushMatrix, + glPopMatrix, + glTranslatef, + glRotatef, + GL_BLEND, + glBlendFunc, + GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, ) @@ -48,10 +74,13 @@ def get_asset_search_paths() -> Tuple[Optional[str], List[str]]: """Get the current asset resolution context.""" return _primary_project_folder, _asset_search_paths + class BaseLayoutElement(ABC): """Abstract base class for all layout elements""" - def __init__(self, x: float = 0, y: float = 0, width: float = 100, height: float = 100, rotation: float = 0, z_index: int = 0): + def __init__( + self, x: float = 0, y: float = 0, width: float = 100, height: float = 100, rotation: float = 0, z_index: int = 0 + ): self.position = (x, y) self.size = (width, height) self.rotation = rotation @@ -118,11 +147,17 @@ class BaseLayoutElement(ABC): """Deserialize from a dictionary""" pass + class ImageData(BaseLayoutElement): """Class to store image data and properties""" - def __init__(self, image_path: str = "", crop_info: Optional[Tuple] = None, - image_dimensions: Optional[Tuple[int, int]] = None, **kwargs): + def __init__( + self, + image_path: str = "", + crop_info: Optional[Tuple] = None, + image_dimensions: Optional[Tuple[int, int]] = None, + **kwargs, + ): super().__init__(**kwargs) self.image_path = image_path self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop @@ -190,15 +225,15 @@ class ImageData(BaseLayoutElement): # Create texture from pending image if one exists (deferred from async load) # Texture creation must happen during render when GL context is active - if hasattr(self, '_pending_pil_image') and self._pending_pil_image is not None: + if hasattr(self, "_pending_pil_image") and self._pending_pil_image is not None: self._create_texture_from_pending_image() # Use cached texture if available - if hasattr(self, '_texture_id') and self._texture_id: + if hasattr(self, "_texture_id") and self._texture_id: texture_id = self._texture_id # Get image dimensions (from loaded texture or metadata) - if hasattr(self, '_img_width') and hasattr(self, '_img_height'): + if hasattr(self, "_img_width") and hasattr(self, "_img_height"): img_width, img_height = self._img_width, self._img_height elif self.image_dimensions: img_width, img_height = self.image_dimensions @@ -207,9 +242,7 @@ class ImageData(BaseLayoutElement): img_width, img_height = int(w), int(h) # Calculate texture coordinates for center crop with element's crop_info - tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords( - img_width, img_height, w, h, self.crop_info - ) + tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords(img_width, img_height, w, h, self.crop_info) # Enable texturing and draw with crop glEnable(GL_TEXTURE_2D) @@ -217,14 +250,18 @@ class ImageData(BaseLayoutElement): glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is glBegin(GL_QUADS) - glTexCoord2f(tx_min, ty_min); glVertex2f(x, y) - glTexCoord2f(tx_max, ty_min); glVertex2f(x + w, y) - glTexCoord2f(tx_max, ty_max); glVertex2f(x + w, y + h) - glTexCoord2f(tx_min, ty_max); glVertex2f(x, y + h) + glTexCoord2f(tx_min, ty_min) + glVertex2f(x, y) + glTexCoord2f(tx_max, ty_min) + glVertex2f(x + w, y) + glTexCoord2f(tx_max, ty_max) + glVertex2f(x + w, y + h) + glTexCoord2f(tx_min, ty_max) + glVertex2f(x, y + h) glEnd() glDisable(GL_TEXTURE_2D) - + # If no image or loading failed, draw placeholder if not texture_id: glColor3f(0.7, 0.85, 1.0) # Light blue @@ -234,7 +271,7 @@ class ImageData(BaseLayoutElement): glVertex2f(x + w, y + h) glVertex2f(x, y + h) glEnd() - + # Draw border glColor3f(0.0, 0.0, 0.0) # Black border glBegin(GL_LINE_LOOP) @@ -254,7 +291,7 @@ class ImageData(BaseLayoutElement): "z_index": self.z_index, "image_path": self.image_path, "crop_info": self.crop_info, - "pil_rotation_90": getattr(self, 'pil_rotation_90', 0) + "pil_rotation_90": getattr(self, "pil_rotation_90", 0), } # Include image dimensions metadata if available if self.image_dimensions: @@ -313,7 +350,7 @@ class ImageData(BaseLayoutElement): logger.debug(f"ImageData: Async load completed for {self.image_path}, size: {pil_image.size}") # Apply PIL-level rotation if needed - if hasattr(self, 'pil_rotation_90') and self.pil_rotation_90 > 0: + if hasattr(self, "pil_rotation_90") and self.pil_rotation_90 > 0: pil_image = apply_pil_rotation(pil_image, self.pil_rotation_90) logger.debug(f"ImageData: Applied PIL rotation {self.pil_rotation_90 * 90}° to {self.image_path}") @@ -339,7 +376,7 @@ class ImageData(BaseLayoutElement): Create OpenGL texture from pending PIL image. Called during render() when GL context is active. """ - if not hasattr(self, '_pending_pil_image') or self._pending_pil_image is None: + if not hasattr(self, "_pending_pil_image") or self._pending_pil_image is None: return False try: @@ -355,11 +392,11 @@ class ImageData(BaseLayoutElement): pil_image = self._pending_pil_image # Ensure RGBA format for GL_RGBA texture (defensive check) - if pil_image.mode != 'RGBA': - pil_image = pil_image.convert('RGBA') + if pil_image.mode != "RGBA": + pil_image = pil_image.convert("RGBA") # Delete old texture if it exists - if hasattr(self, '_texture_id') and self._texture_id: + if hasattr(self, "_texture_id") and self._texture_id: glDeleteTextures([self._texture_id]) # Create GPU texture from pre-processed PIL image @@ -369,8 +406,9 @@ class ImageData(BaseLayoutElement): glBindTexture(GL_TEXTURE_2D, texture_id) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, pil_image.width, pil_image.height, - 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data) + glTexImage2D( + GL_TEXTURE_2D, 0, GL_RGBA, pil_image.width, pil_image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data + ) # Cache texture self._texture_id = texture_id @@ -380,8 +418,8 @@ class ImageData(BaseLayoutElement): self._pending_pil_image = None # Clear the warning flag if we successfully created the texture - if hasattr(self, '_gl_context_warned'): - delattr(self, '_gl_context_warned') + if hasattr(self, "_gl_context_warned"): + delattr(self, "_gl_context_warned") logger.info(f"ImageData: Successfully created texture for {self.image_path}") return True @@ -390,11 +428,13 @@ class ImageData(BaseLayoutElement): error_str = str(e) # Check if this is a GL context error (err 1282 = GL_INVALID_OPERATION) # These are typically caused by no GL context being current - if 'GLError' in error_str and '1282' in error_str: + if "GLError" in error_str and "1282" in error_str: # GL context not ready - keep pending image and try again next render # Don't spam the console with repeated messages - if not hasattr(self, '_gl_context_warned'): - logger.warning(f"ImageData: GL context error (1282) for {self.image_path}, will retry on next render") + if not hasattr(self, "_gl_context_warned"): + logger.warning( + f"ImageData: GL context error (1282) for {self.image_path}, will retry on next render" + ) self._gl_context_warned = True return False else: @@ -415,6 +455,7 @@ class ImageData(BaseLayoutElement): self._async_loading = False self._async_load_requested = False + class PlaceholderData(BaseLayoutElement): """Class to store placeholder data""" @@ -428,7 +469,7 @@ class PlaceholderData(BaseLayoutElement): x, y = self.position w, h = self.size - + # Apply rotation if needed if self.rotation != 0: glPushMatrix() @@ -440,7 +481,7 @@ class PlaceholderData(BaseLayoutElement): glTranslatef(-w / 2, -h / 2, 0) # Now render at origin (rotation pivot is at element center) x, y = 0, 0 - + # Draw a light gray rectangle as placeholder background glColor3f(0.9, 0.9, 0.9) # Light gray glBegin(GL_QUADS) @@ -449,7 +490,7 @@ class PlaceholderData(BaseLayoutElement): glVertex2f(x + w, y + h) glVertex2f(x, y + h) glEnd() - + # Draw dashed border for placeholder glEnable(GL_LINE_STIPPLE) glLineStipple(1, 0x00FF) # Dashed pattern @@ -461,7 +502,7 @@ class PlaceholderData(BaseLayoutElement): glVertex2f(x, y + h) glEnd() glDisable(GL_LINE_STIPPLE) - + # Pop matrix if we pushed for rotation if self.rotation != 0: glPopMatrix() @@ -475,7 +516,7 @@ class PlaceholderData(BaseLayoutElement): "rotation": self.rotation, "z_index": self.z_index, "placeholder_type": self.placeholder_type, - "default_content": self.default_content + "default_content": self.default_content, } # Add base fields (v3.0+) data.update(self._serialize_base_fields()) @@ -493,6 +534,7 @@ class PlaceholderData(BaseLayoutElement): self.placeholder_type = data.get("placeholder_type", "image") self.default_content = data.get("default_content", "") + class TextBoxData(BaseLayoutElement): """Class to store text box data""" @@ -506,7 +548,7 @@ class TextBoxData(BaseLayoutElement): """Render the text box using OpenGL""" x, y = self.position w, h = self.size - + # Apply rotation if needed if self.rotation != 0: glPushMatrix() @@ -518,11 +560,11 @@ class TextBoxData(BaseLayoutElement): glTranslatef(-w / 2, -h / 2, 0) # Now render at origin (rotation pivot is at element center) x, y = 0, 0 - + # Enable alpha blending for transparency glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - + # Draw a semi-transparent yellow rectangle as text box background glColor4f(1.0, 1.0, 0.7, 0.3) # Light yellow with 30% opacity glBegin(GL_QUADS) @@ -531,9 +573,9 @@ class TextBoxData(BaseLayoutElement): glVertex2f(x + w, y + h) glVertex2f(x, y + h) glEnd() - + glDisable(GL_BLEND) - + # Draw border glColor3f(0.0, 0.0, 0.0) # Black border glBegin(GL_LINE_LOOP) @@ -542,11 +584,11 @@ class TextBoxData(BaseLayoutElement): glVertex2f(x + w, y + h) glVertex2f(x, y + h) glEnd() - + # Pop matrix if we pushed for rotation if self.rotation != 0: glPopMatrix() - + # Note: Text content is rendered using QPainter overlay in GLWidget.paintGL() def serialize(self) -> Dict[str, Any]: @@ -559,7 +601,7 @@ class TextBoxData(BaseLayoutElement): "z_index": self.z_index, "text_content": self.text_content, "font_settings": self.font_settings, - "alignment": self.alignment + "alignment": self.alignment, } # Add base fields (v3.0+) data.update(self._serialize_base_fields()) @@ -578,6 +620,7 @@ class TextBoxData(BaseLayoutElement): self.font_settings = data.get("font_settings", {"family": "Arial", "size": 12, "color": (0, 0, 0)}) self.alignment = data.get("alignment", "left") + class GhostPageData(BaseLayoutElement): """Class to represent a ghost page placeholder for alignment in double-page spreads""" @@ -592,17 +635,17 @@ class GhostPageData(BaseLayoutElement): # Render at page origin (0,0) in page-local coordinates # PageRenderer will handle transformation to screen coordinates x, y = 0, 0 - + # Calculate dimensions from page_size (in mm) - assume 300 DPI for now # This will be overridden by proper size calculation in PageRenderer dpi = 300 # Default DPI for rendering w = self.page_size[0] * dpi / 25.4 h = self.page_size[1] * dpi / 25.4 - + # Enable alpha blending for transparency glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - + # Draw a light grey semi-transparent rectangle as ghost page background glColor4f(0.8, 0.8, 0.8, 0.5) # Light grey with 50% opacity glBegin(GL_QUADS) @@ -611,9 +654,9 @@ class GhostPageData(BaseLayoutElement): glVertex2f(x + w, y + h) glVertex2f(x, y + h) glEnd() - + glDisable(GL_BLEND) - + # Draw dashed border glEnable(GL_LINE_STIPPLE) glLineStipple(2, 0x0F0F) # Dashed pattern @@ -625,7 +668,7 @@ class GhostPageData(BaseLayoutElement): glVertex2f(x, y + h) glEnd() glDisable(GL_LINE_STIPPLE) - + # Note: "Click to Add Page" text is rendered using QPainter overlay in GLWidget # The entire page is clickable @@ -640,12 +683,7 @@ class GhostPageData(BaseLayoutElement): def serialize(self) -> Dict[str, Any]: """Serialize ghost page data to dictionary""" - data = { - "type": "ghostpage", - "position": self.position, - "size": self.size, - "page_size": self.page_size - } + data = {"type": "ghostpage", "position": self.position, "size": self.size, "page_size": self.page_size} # Add base fields (v3.0+) data.update(self._serialize_base_fields()) return data diff --git a/pyPhotoAlbum/page_layout.py b/pyPhotoAlbum/page_layout.py index bc6d2a5..a812f77 100644 --- a/pyPhotoAlbum/page_layout.py +++ b/pyPhotoAlbum/page_layout.py @@ -6,19 +6,32 @@ from typing import List, Dict, Any, Optional, Tuple from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData from pyPhotoAlbum.snapping import SnappingSystem from pyPhotoAlbum.gl_imports import ( - glBegin, glEnd, glVertex2f, glColor3f, glColor4f, - GL_QUADS, GL_LINE_LOOP, GL_LINES, glLineWidth, - glEnable, glDisable, GL_DEPTH_TEST, GL_BLEND, - glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, + glBegin, + glEnd, + glVertex2f, + glColor3f, + glColor4f, + GL_QUADS, + GL_LINE_LOOP, + GL_LINES, + glLineWidth, + glEnable, + glDisable, + GL_DEPTH_TEST, + GL_BLEND, + glBlendFunc, + GL_SRC_ALPHA, + GL_ONE_MINUS_SRC_ALPHA, ) + class PageLayout: """Class to manage page layout and templates""" def __init__(self, width: float = 210, height: float = 297, is_facing_page: bool = False): """ Initialize page layout. - + Args: width: Width in mm (doubled automatically if is_facing_page=True) height: Height in mm @@ -41,7 +54,7 @@ class PageLayout: """Remove a layout element from the page""" self.elements.remove(element) - def set_grid_layout(self, grid: 'GridLayout'): + def set_grid_layout(self, grid: "GridLayout"): """Set a grid layout for the page""" self.grid_layout = grid @@ -58,15 +71,15 @@ class PageLayout: """ # Disable depth testing for 2D rendering glDisable(GL_DEPTH_TEST) - + # Convert size from mm to pixels based on DPI width_px = self.size[0] * dpi / 25.4 height_px = self.size[1] * dpi / 25.4 - + # All rendering is at page origin (0, 0) in page-local coordinates page_x = 0 page_y = 0 - + # Draw drop shadow FIRST (behind everything) shadow_offset = 5 glColor3f(0.5, 0.5, 0.5) @@ -76,14 +89,14 @@ class PageLayout: glVertex2f(page_x + width_px + shadow_offset, page_y + height_px + shadow_offset) glVertex2f(page_x + shadow_offset, page_y + height_px + shadow_offset) glEnd() - + glBegin(GL_QUADS) glVertex2f(page_x + width_px, page_y + shadow_offset) glVertex2f(page_x + width_px + shadow_offset, page_y + shadow_offset) glVertex2f(page_x + width_px + shadow_offset, page_y + height_px) glVertex2f(page_x + width_px, page_y + height_px) glEnd() - + # Draw page background (slightly off-white to distinguish from canvas) glColor3f(0.98, 0.98, 0.98) glBegin(GL_QUADS) @@ -97,11 +110,11 @@ class PageLayout: # For ImageData elements, request async loading if available for element in self.elements: # Check if this is an ImageData element that needs async loading - if isinstance(element, ImageData) and not hasattr(element, '_texture_id'): + if isinstance(element, ImageData) and not hasattr(element, "_texture_id"): # Try to get async loader from a parent widget - if hasattr(self, '_async_loader'): + if hasattr(self, "_async_loader"): loader = self._async_loader - elif hasattr(self, '_parent_widget') and hasattr(self._parent_widget, 'async_image_loader'): + elif hasattr(self, "_parent_widget") and hasattr(self._parent_widget, "async_image_loader"): loader = self._parent_widget.async_image_loader else: loader = None @@ -109,14 +122,15 @@ class PageLayout: # Request async load if loader is available and not already requested if loader and not element._async_load_requested: from pyPhotoAlbum.async_backend import LoadPriority + # Determine priority based on visibility (HIGH for now, can be refined) - if hasattr(self._parent_widget, 'request_image_load'): + if hasattr(self._parent_widget, "request_image_load"): self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH) element._async_load_requested = True element._async_loading = True element.render() - + # Draw page border LAST (on top of everything) glColor3f(0.7, 0.7, 0.7) glLineWidth(2.0) @@ -127,7 +141,7 @@ class PageLayout: glVertex2f(page_x, page_y + height_px) glEnd() glLineWidth(1.0) - + # Draw center line for facing pages if self.is_facing_page: center_x = page_x + (width_px / 2) @@ -138,13 +152,13 @@ class PageLayout: glVertex2f(center_x, page_y + height_px) glEnd() glLineWidth(1.0) - + # Always render snap lines (grid shows when show_grid is on, guides show when show_snap_lines is on) self._render_snap_lines(dpi, page_x, page_y, project) # Re-enable depth testing glEnable(GL_DEPTH_TEST) - + def _render_snap_lines(self, dpi: int, page_x: float, page_y: float, project=None): """Render snap lines (grid, edges, guides)""" # Use project settings if available, otherwise fall back to local snapping_system @@ -169,6 +183,7 @@ class PageLayout: # Create a temporary snapping system with project settings to get snap lines from pyPhotoAlbum.snapping import SnappingSystem + temp_snap_sys = SnappingSystem(snap_threshold_mm=snap_threshold_mm) temp_snap_sys.grid_size_mm = grid_size_mm temp_snap_sys.snap_to_grid = snap_to_grid @@ -183,12 +198,12 @@ class PageLayout: glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) # Draw grid lines (darker gray with transparency) - visible when show_grid is enabled - if show_grid and snap_lines['grid']: + if show_grid and snap_lines["grid"]: glColor4f(0.6, 0.6, 0.6, 0.4) # Gray with 40% opacity glLineWidth(1.0) - for orientation, position in snap_lines['grid']: + for orientation, position in snap_lines["grid"]: glBegin(GL_LINES) - if orientation == 'vertical': + if orientation == "vertical": glVertex2f(page_x + position, page_y) glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4) else: # horizontal @@ -197,12 +212,12 @@ class PageLayout: glEnd() # Draw guides (cyan, more visible with transparency) - only show when show_snap_lines is on - if show_snap_lines and snap_lines['guides']: + if show_snap_lines and snap_lines["guides"]: glColor4f(0.0, 0.7, 0.9, 0.8) # Cyan with 80% opacity glLineWidth(1.5) - for orientation, position in snap_lines['guides']: + for orientation, position in snap_lines["guides"]: glBegin(GL_LINES) - if orientation == 'vertical': + if orientation == "vertical": glVertex2f(page_x + position, page_y) glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4) else: # horizontal @@ -223,7 +238,7 @@ class PageLayout: "elements": [elem.serialize() for elem in self.elements], "grid_layout": self.grid_layout.serialize() if self.grid_layout else None, "snapping_system": self.snapping_system.serialize(), - "show_snap_lines": self.show_snap_lines + "show_snap_lines": self.show_snap_lines, } def deserialize(self, data: Dict[str, Any]): @@ -250,7 +265,7 @@ class PageLayout: elem.deserialize(elem_data) elem_list.append(elem) - + # Sort by z_index to establish proper list order (lower z_index = earlier in list = behind) elem_list.sort(key=lambda e: e.z_index) self.elements = elem_list @@ -260,14 +275,15 @@ class PageLayout: if grid_data: self.grid_layout = GridLayout() self.grid_layout.deserialize(grid_data) - + # Deserialize snapping system snap_data = data.get("snapping_system") if snap_data: self.snapping_system.deserialize(snap_data) - + self.show_snap_lines = data.get("show_snap_lines", True) + class GridLayout: """Class to manage grid layouts""" @@ -281,7 +297,9 @@ class GridLayout: """Merge cells in the grid""" self.merged_cells.append((row, col)) - def get_cell_position(self, row: int, col: int, page_width: float = 800, page_height: float = 600) -> Tuple[float, float]: + def get_cell_position( + self, row: int, col: int, page_width: float = 800, page_height: float = 600 + ) -> Tuple[float, float]: """Get the position of a grid cell""" cell_width = (page_width - (self.spacing * (self.columns + 1))) / self.columns cell_height = (page_height - (self.spacing * (self.rows + 1))) / self.rows @@ -300,12 +318,7 @@ class GridLayout: def serialize(self) -> Dict[str, Any]: """Serialize grid layout to dictionary""" - return { - "rows": self.rows, - "columns": self.columns, - "spacing": self.spacing, - "merged_cells": self.merged_cells - } + return {"rows": self.rows, "columns": self.columns, "spacing": self.spacing, "merged_cells": self.merged_cells} def deserialize(self, data: Dict[str, Any]): """Deserialize from dictionary""" diff --git a/pyPhotoAlbum/page_renderer.py b/pyPhotoAlbum/page_renderer.py index 25fba26..219a85c 100644 --- a/pyPhotoAlbum/page_renderer.py +++ b/pyPhotoAlbum/page_renderer.py @@ -17,21 +17,17 @@ from pyPhotoAlbum.gl_imports import glPushMatrix, glPopMatrix, glScalef, glTrans class PageRenderer: """ Handles rendering and coordinate transformations for a single page. - + This class encapsulates all coordinate transformations needed to render a page and its elements consistently. """ - - def __init__(self, - page_width_mm: float, - page_height_mm: float, - screen_x: float, - screen_y: float, - dpi: int, - zoom: float): + + def __init__( + self, page_width_mm: float, page_height_mm: float, screen_x: float, screen_y: float, dpi: int, zoom: float + ): """ Initialize a page renderer. - + Args: page_width_mm: Page width in millimeters page_height_mm: Page height in millimeters @@ -46,81 +42,83 @@ class PageRenderer: self.screen_y = screen_y self.dpi = dpi self.zoom = zoom - + # Calculate page dimensions in pixels self.page_width_px = page_width_mm * dpi / 25.4 self.page_height_px = page_height_mm * dpi / 25.4 - + # Calculate screen dimensions (with zoom applied) self.screen_width = self.page_width_px * zoom self.screen_height = self.page_height_px * zoom - + def page_to_screen(self, page_x: float, page_y: float) -> Tuple[float, float]: """ Convert page-local coordinates (in pixels) to screen coordinates. - + Args: page_x: X coordinate in page-local space (pixels) page_y: Y coordinate in page-local space (pixels) - + Returns: Tuple of (screen_x, screen_y) """ screen_x = self.screen_x + page_x * self.zoom screen_y = self.screen_y + page_y * self.zoom return (screen_x, screen_y) - + def screen_to_page(self, screen_x: float, screen_y: float) -> Tuple[float, float]: """ Convert screen coordinates to page-local coordinates (in pixels). - + Args: screen_x: X coordinate in screen space screen_y: Y coordinate in screen space - + Returns: Tuple of (page_x, page_y) in pixels, or None if outside page bounds """ page_x = (screen_x - self.screen_x) / self.zoom page_y = (screen_y - self.screen_y) / self.zoom return (page_x, page_y) - + def is_point_in_page(self, screen_x: float, screen_y: float) -> bool: """ Check if a screen coordinate is within the page bounds. - + Args: screen_x: X coordinate in screen space screen_y: Y coordinate in screen space - + Returns: True if the point is within the page bounds """ - return (self.screen_x <= screen_x <= self.screen_x + self.screen_width and - self.screen_y <= screen_y <= self.screen_y + self.screen_height) - + return ( + self.screen_x <= screen_x <= self.screen_x + self.screen_width + and self.screen_y <= screen_y <= self.screen_y + self.screen_height + ) + def get_sub_page_at(self, screen_x: float, is_facing_page: bool) -> Optional[str]: """ For facing page spreads, determine if mouse is on left or right page. - + Args: screen_x: X coordinate in screen space is_facing_page: Whether this is a facing page spread - + Returns: 'left' or 'right' for facing pages, None for single pages """ if not is_facing_page: return None - + # Calculate the center line of the spread center_x = self.screen_x + self.screen_width / 2 - + if screen_x < center_x: - return 'left' + return "left" else: - return 'right' - + return "right" + def begin_render(self): """ Set up OpenGL transformations for rendering this page. @@ -131,27 +129,27 @@ class PageRenderer: glScalef(self.zoom, self.zoom, 1.0) # Translate to page position (in zoomed coordinates) glTranslatef(self.screen_x / self.zoom, self.screen_y / self.zoom, 0.0) - + def end_render(self): """ Clean up OpenGL transformations after rendering this page. Call this after rendering page content. """ glPopMatrix() - + def get_page_bounds_screen(self) -> Tuple[float, float, float, float]: """ Get the page bounds in screen coordinates. - + Returns: Tuple of (x, y, width, height) in screen space """ return (self.screen_x, self.screen_y, self.screen_width, self.screen_height) - + def get_page_bounds_page(self) -> Tuple[float, float, float, float]: """ Get the page bounds in page-local coordinates. - + Returns: Tuple of (x, y, width, height) in page-local space (pixels) """ diff --git a/pyPhotoAlbum/pdf_exporter.py b/pyPhotoAlbum/pdf_exporter.py index 64b6855..c8f9c4f 100644 --- a/pyPhotoAlbum/pdf_exporter.py +++ b/pyPhotoAlbum/pdf_exporter.py @@ -25,6 +25,7 @@ from pyPhotoAlbum.image_utils import ( @dataclass class RenderContext: """Parameters for rendering an image element""" + canvas: canvas.Canvas image_element: ImageData x_pt: float @@ -41,6 +42,7 @@ class RenderContext: @dataclass class SplitRenderParams: """Parameters for rendering a split element""" + canvas: canvas.Canvas element: any x_offset_mm: float @@ -53,15 +55,15 @@ class SplitRenderParams: class PDFExporter: """Handles PDF export of photo album projects""" - + # Conversion constants MM_TO_POINTS = 2.834645669 # 1mm = 2.834645669 points SPLIT_THRESHOLD_RATIO = 0.002 # 1:500 threshold for tiny elements - + def __init__(self, project, export_dpi: int = 300): """ Initialize PDF exporter with a project. - + Args: project: The Project instance to export export_dpi: Target DPI for images in the PDF (default 300 for print quality) @@ -71,48 +73,46 @@ class PDFExporter: self.export_dpi = export_dpi self.warnings = [] self.current_pdf_page = 1 - + def export(self, output_path: str, progress_callback=None) -> Tuple[bool, List[str]]: """ Export the project to PDF. - + Args: output_path: Path where PDF should be saved progress_callback: Optional callback(current, total, message) for progress updates - + Returns: Tuple of (success: bool, warnings: List[str]) """ self.warnings = [] self.current_pdf_page = 1 - + try: # Calculate total pages for progress (cover counts as 1) total_pages = sum( - 1 if page.is_cover else (2 if page.is_double_spread else 1) - for page in self.project.pages + 1 if page.is_cover else (2 if page.is_double_spread else 1) for page in self.project.pages ) - + # Get page dimensions from project (in mm) page_width_mm, page_height_mm = self.project.page_size_mm - + # Convert to PDF points page_width_pt = page_width_mm * self.MM_TO_POINTS page_height_pt = page_height_mm * self.MM_TO_POINTS - + # Create PDF canvas c = canvas.Canvas(output_path, pagesize=(page_width_pt, page_height_pt)) - + # Process each page pages_processed = 0 for page in self.project.pages: # Get display name for progress page_name = self.project.get_page_display_name(page) - + if progress_callback: - progress_callback(pages_processed, total_pages, - f"Exporting {page_name}...") - + progress_callback(pages_processed, total_pages, f"Exporting {page_name}...") + if page.is_cover: # Export cover as single page with wrap-around design self._export_cover(c, page, page_width_pt, page_height_pt) @@ -124,9 +124,8 @@ class PDFExporter: c.showPage() # Finish current page self.current_pdf_page += 1 if progress_callback: - progress_callback(pages_processed, total_pages, - f"Inserting blank page for alignment...") - + progress_callback(pages_processed, total_pages, f"Inserting blank page for alignment...") + # Export spread as two pages self._export_spread(c, page, page_width_pt, page_height_pt) pages_processed += 2 @@ -134,112 +133,109 @@ class PDFExporter: # Export single page self._export_single_page(c, page, page_width_pt, page_height_pt) pages_processed += 1 - + # Save PDF c.save() - + if progress_callback: progress_callback(total_pages, total_pages, "Export complete!") - + return True, self.warnings - + except Exception as e: self.warnings.append(f"Export failed: {str(e)}") return False, self.warnings - - def _export_cover(self, c: canvas.Canvas, page, page_width_pt: float, - page_height_pt: float): + + def _export_cover(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float): """ Export a cover page to PDF. Cover has different dimensions (wrap-around: front + spine + back + bleed). """ # Get cover dimensions (already calculated in page.layout.size) cover_width_mm, cover_height_mm = page.layout.size - + # Convert to PDF points cover_width_pt = cover_width_mm * self.MM_TO_POINTS cover_height_pt = cover_height_mm * self.MM_TO_POINTS - + # Create a new page with cover dimensions c.setPageSize((cover_width_pt, cover_height_pt)) - + # Render all elements on the cover for element in sorted(page.layout.elements, key=lambda x: x.z_index): self._render_element(c, element, 0, cover_width_pt, cover_height_pt, "Cover") - + # Draw guide lines for front/spine/back zones self._draw_cover_guides(c, cover_width_pt, cover_height_pt) - + c.showPage() # Finish cover page self.current_pdf_page += 1 - + # Reset page size for content pages c.setPageSize((page_width_pt, page_height_pt)) - + def _draw_cover_guides(self, c: canvas.Canvas, cover_width_pt: float, cover_height_pt: float): """Draw guide lines for cover zones (front/spine/back)""" from reportlab.lib.colors import lightgrey - + # Calculate zone boundaries bleed_pt = self.project.cover_bleed_mm * self.MM_TO_POINTS page_width_pt = self.project.page_size_mm[0] * self.MM_TO_POINTS spine_width_pt = self.project.calculate_spine_width() * self.MM_TO_POINTS - + # Zone boundaries (from left to right) # Bleed | Back | Spine | Front | Bleed back_start = bleed_pt spine_start = bleed_pt + page_width_pt front_start = bleed_pt + page_width_pt + spine_width_pt front_end = bleed_pt + page_width_pt + spine_width_pt + page_width_pt - + # Draw dashed lines at zone boundaries c.saveState() c.setStrokeColor(lightgrey) c.setDash(3, 3) c.setLineWidth(0.5) - + # Back/Spine boundary c.line(spine_start, 0, spine_start, cover_height_pt) - + # Spine/Front boundary c.line(front_start, 0, front_start, cover_height_pt) - + # Bleed boundaries (outer edges) if bleed_pt > 0: c.line(back_start, 0, back_start, cover_height_pt) c.line(front_end, 0, front_end, cover_height_pt) - + c.restoreState() - - def _export_single_page(self, c: canvas.Canvas, page, page_width_pt: float, - page_height_pt: float): + + def _export_single_page(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float): """Export a single page to PDF""" # Render all elements for element in sorted(page.layout.elements, key=lambda x: x.z_index): self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number) - + c.showPage() # Finish this page self.current_pdf_page += 1 - - def _export_spread(self, c: canvas.Canvas, page, page_width_pt: float, - page_height_pt: float): + + def _export_spread(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float): """Export a double-page spread as two PDF pages""" # Get center line position in mm page_width_mm = self.project.page_size_mm[0] center_mm = page_width_mm # Center of the spread (which is 2x width) - + # Convert center line to pixels for comparison dpi = self.project.working_dpi center_px = center_mm * dpi / 25.4 - + # Calculate threshold for tiny elements (1:500) in pixels threshold_px = page_width_mm * self.SPLIT_THRESHOLD_RATIO * dpi / 25.4 - + # Process elements for left page for element in sorted(page.layout.elements, key=lambda x: x.z_index): element_x_px, element_y_px = element.position element_width_px, element_height_px = element.size - + # Check if element is on left page, right page, or spanning (compare in pixels) if element_x_px + element_width_px <= center_px + threshold_px: # Entirely on left page @@ -257,23 +253,22 @@ class PDFExporter: page_width_pt=page_width_pt, page_height_pt=page_height_pt, page_number=page.page_number, - side='left' + side="left", ) self._render_split_element(params) - + c.showPage() # Finish left page self.current_pdf_page += 1 - + # Process elements for right page for element in sorted(page.layout.elements, key=lambda x: x.z_index): element_x_px, element_y_px = element.position element_width_px, element_height_px = element.size - + # Check if element is on right page or spanning (compare in pixels) if element_x_px >= center_px - threshold_px and element_x_px + element_width_px > center_px: # Entirely on right page or mostly on right - self._render_element(c, element, center_mm, page_width_pt, page_height_pt, - page.page_number + 1) + self._render_element(c, element, center_mm, page_width_pt, page_height_pt, page.page_number + 1) elif element_x_px < center_px and element_x_px + element_width_px > center_px + threshold_px: # Spanning element - render right portion params = SplitRenderParams( @@ -284,18 +279,25 @@ class PDFExporter: page_width_pt=page_width_pt, page_height_pt=page_height_pt, page_number=page.page_number + 1, - side='right' + side="right", ) self._render_split_element(params) - + c.showPage() # Finish right page self.current_pdf_page += 1 - - def _render_element(self, c: canvas.Canvas, element, x_offset_mm: float, - page_width_pt: float, page_height_pt: float, page_number: int): + + def _render_element( + self, + c: canvas.Canvas, + element, + x_offset_mm: float, + page_width_pt: float, + page_height_pt: float, + page_number: int, + ): """ Render a single element on the PDF canvas. - + Args: c: ReportLab canvas element: The layout element to render @@ -307,27 +309,27 @@ class PDFExporter: # Skip placeholders if isinstance(element, PlaceholderData): return - + # Get element position and size (in PIXELS from OpenGL coordinates) element_x_px, element_y_px = element.position element_width_px, element_height_px = element.size - + # Convert from pixels to mm using the working DPI dpi = self.project.working_dpi element_x_mm = element_x_px * 25.4 / dpi element_y_mm = element_y_px * 25.4 / dpi element_width_mm = element_width_px * 25.4 / dpi element_height_mm = element_height_px * 25.4 / dpi - + # Adjust x position for offset (now in mm) adjusted_x_mm = element_x_mm - x_offset_mm - + # Convert to PDF points and flip Y coordinate (PDF origin is bottom-left) x_pt = adjusted_x_mm * self.MM_TO_POINTS y_pt = page_height_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS) width_pt = element_width_mm * self.MM_TO_POINTS height_pt = element_height_mm * self.MM_TO_POINTS - + if isinstance(element, ImageData): ctx = RenderContext( canvas=c, @@ -336,12 +338,12 @@ class PDFExporter: y_pt=y_pt, width_pt=width_pt, height_pt=height_pt, - page_number=page_number + page_number=page_number, ) self._render_image(ctx) elif isinstance(element, TextBoxData): self._render_textbox(c, element, x_pt, y_pt, width_pt, height_pt) - + def _render_split_element(self, params: SplitRenderParams): """ Render a split element (only the portion on one side of the split line). @@ -352,7 +354,7 @@ class PDFExporter: # Skip placeholders if isinstance(params.element, PlaceholderData): return - + # Get element position and size in pixels element_x_px, element_y_px = params.element.position element_width_px, element_height_px = params.element.size @@ -366,7 +368,7 @@ class PDFExporter: if isinstance(params.element, ImageData): # Calculate which portion of the image to render - if params.side == 'left': + if params.side == "left": # Render from element start to split line crop_width_mm = params.split_line_mm - element_x_mm crop_x_start = 0 @@ -402,7 +404,7 @@ class PDFExporter: crop_left=crop_x_start / element_width_mm, crop_right=(crop_x_start + crop_width_mm) / element_width_mm, original_width_pt=original_width_pt, - original_height_pt=original_height_pt + original_height_pt=original_height_pt, ) self._render_image(ctx) @@ -410,11 +412,18 @@ class PDFExporter: # For text boxes spanning the split, we'll render the whole text on the side # where most of it appears (simpler than trying to split text) element_center_mm = element_x_mm + element_width_mm / 2 - if (params.side == 'left' and element_center_mm < params.split_line_mm) or \ - (params.side == 'right' and element_center_mm >= params.split_line_mm): - self._render_element(params.canvas, params.element, params.x_offset_mm, - params.page_width_pt, params.page_height_pt, params.page_number) - + if (params.side == "left" and element_center_mm < params.split_line_mm) or ( + params.side == "right" and element_center_mm >= params.split_line_mm + ): + self._render_element( + params.canvas, + params.element, + params.x_offset_mm, + params.page_width_pt, + params.page_height_pt, + params.page_number, + ) + def _render_image(self, ctx: RenderContext): """ Render an image element on the PDF canvas. @@ -438,7 +447,7 @@ class PDFExporter: img = convert_to_rgba(img) # Apply PIL-level rotation if needed - if hasattr(ctx.image_element, 'pil_rotation_90') and ctx.image_element.pil_rotation_90 > 0: + if hasattr(ctx.image_element, "pil_rotation_90") and ctx.image_element.pil_rotation_90 > 0: img = apply_pil_rotation(img, ctx.image_element.pil_rotation_90) # Get element's crop_info and combine with split cropping if applicable @@ -458,27 +467,28 @@ class PDFExporter: # Calculate center crop coordinates img_width, img_height = img.size crop_coords = calculate_center_crop_coords( - img_width, img_height, - target_width, target_height, - (final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max) + img_width, + img_height, + target_width, + target_height, + (final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max), ) # Crop the image cropped_img = crop_image_to_coords(img, crop_coords) - + # Downsample image to target resolution based on export DPI # This prevents embedding huge images and reduces PDF file size # Calculate target dimensions in pixels based on physical size and export DPI target_width_px = int((ctx.width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4) target_height_px = int((ctx.height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4) - + # Only downsample if current image is larger than target # Don't upscale small images as that would reduce quality current_width, current_height = cropped_img.size if current_width > target_width_px or current_height > target_height_px: # Use LANCZOS resampling for high quality downsampling - cropped_img = cropped_img.resize((target_width_px, target_height_px), - Image.Resampling.LANCZOS) + cropped_img = cropped_img.resize((target_width_px, target_height_px), Image.Resampling.LANCZOS) # Note: Rotation is applied at the canvas level (below), not here # to avoid double-rotation issues @@ -495,12 +505,20 @@ class PDFExporter: ctx.canvas.rotate(-ctx.image_element.rotation) ctx.canvas.translate(-ctx.width_pt / 2, -ctx.height_pt / 2) # Draw at origin after transformation - ctx.canvas.drawImage(ImageReader(cropped_img), 0, 0, ctx.width_pt, ctx.height_pt, - mask='auto', preserveAspectRatio=False) + ctx.canvas.drawImage( + ImageReader(cropped_img), 0, 0, ctx.width_pt, ctx.height_pt, mask="auto", preserveAspectRatio=False + ) else: # Draw without rotation - ctx.canvas.drawImage(ImageReader(cropped_img), ctx.x_pt, ctx.y_pt, ctx.width_pt, ctx.height_pt, - mask='auto', preserveAspectRatio=False) + ctx.canvas.drawImage( + ImageReader(cropped_img), + ctx.x_pt, + ctx.y_pt, + ctx.width_pt, + ctx.height_pt, + mask="auto", + preserveAspectRatio=False, + ) ctx.canvas.restoreState() @@ -508,9 +526,10 @@ class PDFExporter: warning = f"Page {ctx.page_number}: Error rendering image {ctx.image_element.image_path}: {str(e)}" print(f"WARNING: {warning}") self.warnings.append(warning) - - def _render_textbox(self, c: canvas.Canvas, text_element: 'TextBoxData', - x_pt: float, y_pt: float, width_pt: float, height_pt: float): + + def _render_textbox( + self, c: canvas.Canvas, text_element: "TextBoxData", x_pt: float, y_pt: float, width_pt: float, height_pt: float + ): """ Render a text box element on the PDF canvas with transparent background. Text is word-wrapped to fit within the box boundaries. @@ -524,9 +543,9 @@ class PDFExporter: return # Get font settings - font_family = text_element.font_settings.get('family', 'Helvetica') - font_size_px = text_element.font_settings.get('size', 12) - font_color = text_element.font_settings.get('color', (0, 0, 0)) + font_family = text_element.font_settings.get("family", "Helvetica") + font_size_px = text_element.font_settings.get("size", 12) + font_color = text_element.font_settings.get("color", (0, 0, 0)) # Convert font size from pixels to PDF points (same conversion as element dimensions) # Font size is stored in pixels at working_dpi, same as element position/size @@ -536,34 +555,32 @@ class PDFExporter: # Map common font names to ReportLab standard fonts font_map = { - 'Arial': 'Helvetica', - 'Times New Roman': 'Times-Roman', - 'Courier New': 'Courier', + "Arial": "Helvetica", + "Times New Roman": "Times-Roman", + "Courier New": "Courier", } font_family = font_map.get(font_family, font_family) # Normalize color to hex for Paragraph style if all(isinstance(x, int) and x > 1 for x in font_color): - color_hex = '#{:02x}{:02x}{:02x}'.format(*font_color) + color_hex = "#{:02x}{:02x}{:02x}".format(*font_color) else: # Convert 0-1 range to 0-255 then to hex - color_hex = '#{:02x}{:02x}{:02x}'.format( - int(font_color[0] * 255), - int(font_color[1] * 255), - int(font_color[2] * 255) + color_hex = "#{:02x}{:02x}{:02x}".format( + int(font_color[0] * 255), int(font_color[1] * 255), int(font_color[2] * 255) ) # Map alignment to ReportLab constants alignment_map = { - 'left': TA_LEFT, - 'center': TA_CENTER, - 'right': TA_RIGHT, + "left": TA_LEFT, + "center": TA_CENTER, + "right": TA_RIGHT, } text_alignment = alignment_map.get(text_element.alignment, TA_LEFT) # Create paragraph style with word wrapping style = ParagraphStyle( - 'textbox', + "textbox", fontName=font_family, fontSize=font_size, leading=font_size * 1.2, # Line spacing (120% of font size) @@ -573,10 +590,10 @@ class PDFExporter: # Escape special XML characters and convert newlines to
tags text_content = text_element.text_content - text_content = text_content.replace('&', '&') - text_content = text_content.replace('<', '<') - text_content = text_content.replace('>', '>') - text_content = text_content.replace('\n', '
') + text_content = text_content.replace("&", "&") + text_content = text_content.replace("<", "<") + text_content = text_content.replace(">", ">") + text_content = text_content.replace("\n", "
") # Create paragraph with the text para = Paragraph(text_content, style) diff --git a/pyPhotoAlbum/project.py b/pyPhotoAlbum/project.py index e8938b0..719479b 100644 --- a/pyPhotoAlbum/project.py +++ b/pyPhotoAlbum/project.py @@ -11,6 +11,7 @@ from pyPhotoAlbum.page_layout import PageLayout from pyPhotoAlbum.commands import CommandHistory from pyPhotoAlbum.asset_manager import AssetManager + class Page: """Class representing a single page in the photo album""" @@ -39,7 +40,7 @@ class Page: # Deletion tracking for merge (v3.0+) self.deleted = False self.deleted_at: Optional[str] = None - + # Create layout with appropriate width if layout is None: self.layout = PageLayout(is_facing_page=is_double_spread) @@ -51,21 +52,21 @@ class Page: self.layout.is_facing_page = is_double_spread height = self.layout.size[1] # Use the base_width if available, otherwise derive it - if hasattr(self.layout, 'base_width'): + if hasattr(self.layout, "base_width"): base_width = self.layout.base_width else: # If base_width not set, assume current width is correct # and derive base_width from current state base_width = self.layout.size[0] / 2 if not is_double_spread else self.layout.size[0] self.layout.base_width = base_width - + # Set the new width based on facing page state self.layout.size = (base_width * 2 if is_double_spread else base_width, height) - + def get_page_numbers(self) -> List[int]: """ Get the page numbers this page represents. - + Returns: List of page numbers (2 numbers for spreads, 1 for single pages) """ @@ -73,7 +74,7 @@ class Page: return [self.page_number, self.page_number + 1] else: return [self.page_number] - + def get_page_count(self) -> int: """ Get the number of physical pages this represents. @@ -133,6 +134,7 @@ class Page: self.layout = PageLayout() self.layout.deserialize(layout_data) + class Project: """Class representing the entire photo album project""" @@ -230,37 +232,33 @@ class Project: if self.has_cover and self.pages: self.update_cover_dimensions() self.mark_dirty() - + def calculate_spine_width(self) -> float: """ Calculate spine width based on page count and paper thickness. - + For saddle stitch binding: - Each sheet = 4 pages (2 pages per side when folded) - Spine width = (Number of sheets × Paper thickness × 2) - + Returns: Spine width in mm """ if not self.has_cover: return 0.0 - + # Count content pages (excluding cover) - content_page_count = sum( - page.get_page_count() - for page in self.pages - if not page.is_cover - ) - + content_page_count = sum(page.get_page_count() for page in self.pages if not page.is_cover) + if self.binding_type == "saddle_stitch": # Calculate number of sheets (each sheet = 4 pages) sheets = math.ceil(content_page_count / 4) # Spine width = sheets × paper thickness × 2 (folded) spine_width = sheets * self.paper_thickness_mm * 2 return spine_width - + return 0.0 - + def update_cover_dimensions(self): """ Update cover page dimensions based on current page count and settings. @@ -268,66 +266,68 @@ class Project: """ if not self.has_cover or not self.pages: return - + # Find cover page (should be first page) cover_page = None for page in self.pages: if page.is_cover: cover_page = page break - + if not cover_page: return - + # Get standard page dimensions page_width_mm, page_height_mm = self.page_size_mm - + # Calculate spine width spine_width = self.calculate_spine_width() - + # Calculate cover dimensions # Cover = Front + Spine + Back + Bleed on all sides cover_width = (page_width_mm * 2) + spine_width + (self.cover_bleed_mm * 2) cover_height = page_height_mm + (self.cover_bleed_mm * 2) - + # Update cover page layout cover_page.layout.size = (cover_width, cover_height) cover_page.layout.base_width = page_width_mm # Store base width for reference cover_page.manually_sized = True # Mark as manually sized - - print(f"Cover dimensions updated: {cover_width:.1f} × {cover_height:.1f} mm " - f"(Front: {page_width_mm}, Spine: {spine_width:.2f}, Back: {page_width_mm}, " - f"Bleed: {self.cover_bleed_mm})") - + + print( + f"Cover dimensions updated: {cover_width:.1f} × {cover_height:.1f} mm " + f"(Front: {page_width_mm}, Spine: {spine_width:.2f}, Back: {page_width_mm}, " + f"Bleed: {self.cover_bleed_mm})" + ) + def get_page_display_name(self, page: Page) -> str: """ Get display name for a page. - + Args: page: The page to get the display name for - + Returns: Display name like "Cover", "Page 1", "Pages 1-2", etc. """ if page.is_cover: return "Cover" - + # Calculate adjusted page number (excluding cover from count) adjusted_num = page.page_number if self.has_cover: # Subtract 1 to account for cover adjusted_num = page.page_number - 1 - + if page.is_double_spread: return f"Pages {adjusted_num}-{adjusted_num + 1}" else: return f"Page {adjusted_num}" - + def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Any, int]]: """ Calculate page layout including ghost pages for alignment. Excludes cover from spread calculations. - + Returns: List of tuples (page_type, page_or_ghost, logical_position) where page_type is 'page' or 'ghost', @@ -335,10 +335,10 @@ class Project: logical_position is the position in the album (1=right, 2=left, etc.) """ from pyPhotoAlbum.models import GhostPageData - + layout = [] current_position = 1 # Start at position 1 (right page) - + for page in self.pages: # Skip cover in spread calculations if page.is_cover: @@ -354,32 +354,32 @@ class Project: elif not page.is_double_spread and current_position % 2 == 1: # Current position is odd (right page), single page is fine pass - + # Actually, let me reconsider the logic: # In a photobook: # - Position 1 is the right page (when opened, first content page) # - Position 2 is the left page of the next spread # - Position 3 is the right page of the next spread # - etc. - # + # # Double spreads occupy TWO positions (both left and right of a spread) # They must start on an even position (left side) so they span across both pages - + # Check if this is a double spread starting at an odd position if page.is_double_spread and current_position % 2 == 1: # Need to insert a ghost page to push the double spread to next position - layout.append(('ghost', None, current_position)) + layout.append(("ghost", None, current_position)) current_position += 1 - + # Add the actual page - layout.append(('page', page, current_position)) - + layout.append(("page", page, current_position)) + # Update position based on page type if page.is_double_spread: current_position += 2 # Double spread takes 2 positions else: current_position += 1 # Single page takes 1 position - + return layout def render_all_pages(self): @@ -453,7 +453,7 @@ class Project: now = datetime.now(timezone.utc).isoformat() self.created = data.get("created", now) self.last_modified = data.get("last_modified", now) - + self.pages = [] # Deserialize asset manager @@ -461,13 +461,13 @@ class Project: asset_data = data.get("asset_manager") if asset_data: self.asset_manager.deserialize(asset_data) - + # Deserialize pages for page_data in data.get("pages", []): page = Page() page.deserialize(page_data) self.pages.append(page) - + # Deserialize command history with asset manager and project reference history_data = data.get("history") if history_data: diff --git a/pyPhotoAlbum/project_serializer.py b/pyPhotoAlbum/project_serializer.py index ef9873e..d4066a1 100644 --- a/pyPhotoAlbum/project_serializer.py +++ b/pyPhotoAlbum/project_serializer.py @@ -14,7 +14,7 @@ from pyPhotoAlbum.version_manager import ( CURRENT_DATA_VERSION, check_version_compatibility, VersionCompatibility, - DataMigration + DataMigration, ) @@ -44,7 +44,7 @@ def _import_external_images(project: Project): # Absolute path - definitely external is_external = True external_path = element.image_path - elif not element.image_path.startswith('assets/'): + elif not element.image_path.startswith("assets/"): # Relative path but not in assets folder # Check if it exists relative to project folder full_path = os.path.join(project.folder_path, element.image_path) @@ -91,15 +91,15 @@ def _normalize_asset_paths(project: Project, project_folder: str): original_path = element.image_path # Skip if already a simple relative path (assets/...) - if not os.path.isabs(original_path) and not original_path.startswith('./projects/'): + if not os.path.isabs(original_path) and not original_path.startswith("./projects/"): continue # Try to extract just the filename or relative path from assets folder # Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg" - if '/assets/' in original_path: - parts = original_path.split('/assets/') + if "/assets/" in original_path: + parts = original_path.split("/assets/") if len(parts) == 2: - new_path = os.path.join('assets', parts[1]) + new_path = os.path.join("assets", parts[1]) element.image_path = new_path normalized_count += 1 print(f"Normalized path: {original_path} -> {new_path}") @@ -133,8 +133,8 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]: """ try: # Ensure .ppz extension - if not zip_path.lower().endswith('.ppz'): - zip_path += '.ppz' + if not zip_path.lower().endswith(".ppz"): + zip_path += ".ppz" # Check for and import any external images before saving _import_external_images(project) @@ -143,14 +143,14 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]: project_data = project.serialize() # Add version information - project_data['serialization_version'] = SERIALIZATION_VERSION # Legacy field - project_data['data_version'] = CURRENT_DATA_VERSION # New versioning system + project_data["serialization_version"] = SERIALIZATION_VERSION # Legacy field + project_data["data_version"] = CURRENT_DATA_VERSION # New versioning system # Create ZIP file - with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf: # Write project.json with stable sorting for git-friendly diffs project_json = json.dumps(project_data, indent=2, sort_keys=True) - zipf.writestr('project.json', project_json) + zipf.writestr("project.json", project_json) # Add all files from the assets folder assets_folder = project.asset_manager.assets_folder @@ -201,20 +201,20 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project: os.makedirs(extract_to, exist_ok=True) # Extract ZIP contents - with zipfile.ZipFile(zip_path, 'r') as zipf: + with zipfile.ZipFile(zip_path, "r") as zipf: zipf.extractall(extract_to) # Load project.json - project_json_path = os.path.join(extract_to, 'project.json') + project_json_path = os.path.join(extract_to, "project.json") if not os.path.exists(project_json_path): raise ValueError("Invalid project file: project.json not found") - with open(project_json_path, 'r') as f: + with open(project_json_path, "r") as f: project_data = json.load(f) # Check version compatibility # Try new version field first, fall back to legacy field - file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0')) + file_version = project_data.get("data_version", project_data.get("serialization_version", "1.0")) # Check if version is compatible is_compatible, error_msg = check_version_compatibility(file_version, zip_path) @@ -230,7 +230,7 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project: print(f"Note: Loading project with version {file_version}, current version is {CURRENT_DATA_VERSION}") # Create new project - project_name = project_data.get('name', 'Untitled Project') + project_name = project_data.get("name", "Untitled Project") project = Project(name=project_name, folder_path=extract_to) # Deserialize project data @@ -254,6 +254,7 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project: # Set asset resolution context for ImageData rendering # Only set project folder - search paths are reserved for healing functionality from pyPhotoAlbum.models import set_asset_resolution_context + set_asset_resolution_context(extract_to) print(f"Project loaded from {zip_path} to {extract_to}") @@ -263,25 +264,25 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project: def get_project_info(zip_path: str) -> Optional[dict]: """ Get basic information about a project without fully loading it. - + Args: zip_path: Path to the ZIP file - + Returns: Dictionary with project info, or None if error """ try: - with zipfile.ZipFile(zip_path, 'r') as zipf: + with zipfile.ZipFile(zip_path, "r") as zipf: # Read project.json - project_json = zipf.read('project.json').decode('utf-8') + project_json = zipf.read("project.json").decode("utf-8") project_data = json.loads(project_json) - + return { - 'name': project_data.get('name', 'Unknown'), - 'version': project_data.get('serialization_version', 'Unknown'), - 'page_count': len(project_data.get('pages', [])), - 'page_size_mm': project_data.get('page_size_mm', (0, 0)), - 'working_dpi': project_data.get('working_dpi', 300), + "name": project_data.get("name", "Unknown"), + "version": project_data.get("serialization_version", "Unknown"), + "page_count": len(project_data.get("pages", [])), + "page_size_mm": project_data.get("page_size_mm", (0, 0)), + "working_dpi": project_data.get("working_dpi", 300), } except Exception as e: print(f"Error reading project info: {e}") diff --git a/pyPhotoAlbum/ribbon_builder.py b/pyPhotoAlbum/ribbon_builder.py index 92df0fd..a7b1900 100644 --- a/pyPhotoAlbum/ribbon_builder.py +++ b/pyPhotoAlbum/ribbon_builder.py @@ -12,14 +12,14 @@ from collections import defaultdict def build_ribbon_config(window_class: Type) -> Dict[str, Any]: """ Extract decorated methods and build ribbon configuration. - + This function scans all methods in the window class and its mixins for methods decorated with @ribbon_action, then builds a nested configuration structure suitable for the RibbonWidget. - + Args: window_class: The MainWindow class with decorated methods - + Returns: Dictionary containing the ribbon configuration with structure: { @@ -42,59 +42,61 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]: """ # Structure to collect actions by tab and group tabs = defaultdict(lambda: defaultdict(list)) - + # Scan all methods in the class and its bases (mixins) for attr_name in dir(window_class): try: attr = getattr(window_class, attr_name) - + # Check if this attribute has ribbon action metadata - if hasattr(attr, '_ribbon_action'): + if hasattr(attr, "_ribbon_action"): action_data = attr._ribbon_action - + # Extract tab and group information - tab_name = action_data['tab'] - group_name = action_data['group'] - + tab_name = action_data["tab"] + group_name = action_data["group"] + # Add action to the appropriate tab and group - tabs[tab_name][group_name].append({ - 'label': action_data['label'], - 'action': action_data['action'], - 'tooltip': action_data['tooltip'], - 'icon': action_data.get('icon'), - 'shortcut': action_data.get('shortcut'), - }) + tabs[tab_name][group_name].append( + { + "label": action_data["label"], + "action": action_data["action"], + "tooltip": action_data["tooltip"], + "icon": action_data.get("icon"), + "shortcut": action_data.get("shortcut"), + } + ) except (AttributeError, TypeError): # Skip attributes that can't be inspected continue - + # Convert to the expected ribbon config format ribbon_config = {} - + # Define tab order (tabs will appear in this order) - tab_order = ['Home', 'Insert', 'Layout', 'Arrange', 'View', 'Export'] - + tab_order = ["Home", "Insert", "Layout", "Arrange", "View", "Export"] + # Add tabs in the defined order, then add any remaining tabs all_tabs = list(tabs.keys()) ordered_tabs = [t for t in tab_order if t in all_tabs] ordered_tabs.extend([t for t in all_tabs if t not in tab_order]) - + for tab_name in ordered_tabs: groups_dict = tabs[tab_name] - + # Convert groups dictionary to list format groups_list = [] - + # Define group order per tab (if needed) group_orders = { - 'Home': ['File', 'Edit'], - 'Insert': ['Media'], - 'Layout': ['Navigation', 'Page', 'Templates'], - 'Arrange': ['Align', 'Size', 'Distribute'], - 'View': ['Zoom'], - 'Export': ['Export'], + "Home": ["File", "Edit"], + "Insert": ["Media"], + "Layout": ["Navigation", "Page", "Templates"], + "Arrange": ["Align", "Size", "Distribute"], + "View": ["Zoom"], + "Export": ["Export"], } - + # Get the group order for this tab, or use alphabetical if tab_name in group_orders: group_order = group_orders[tab_name] @@ -103,139 +105,128 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]: group_order.extend([g for g in all_groups if g not in group_order]) else: group_order = sorted(groups_dict.keys()) - + for group_name in group_order: if group_name in groups_dict: actions = groups_dict[group_name] - groups_list.append({ - 'name': group_name, - 'actions': actions - }) - - ribbon_config[tab_name] = { - 'groups': groups_list - } - + groups_list.append({"name": group_name, "actions": actions}) + + ribbon_config[tab_name] = {"groups": groups_list} + return ribbon_config def get_keyboard_shortcuts(window_class: Type) -> Dict[str, str]: """ Extract keyboard shortcuts from decorated methods. - + Args: window_class: The MainWindow class with decorated methods - + Returns: Dictionary mapping shortcut strings to method names Example: {'Ctrl+N': 'new_project', 'Ctrl+S': 'save_project'} """ shortcuts = {} - + for attr_name in dir(window_class): try: attr = getattr(window_class, attr_name) - - if hasattr(attr, '_ribbon_action'): + + if hasattr(attr, "_ribbon_action"): action_data = attr._ribbon_action - shortcut = action_data.get('shortcut') - + shortcut = action_data.get("shortcut") + if shortcut: - shortcuts[shortcut] = action_data['action'] + shortcuts[shortcut] = action_data["action"] except (AttributeError, TypeError): continue - + return shortcuts def validate_ribbon_config(config: Dict[str, Any]) -> List[str]: """ Validate the ribbon configuration structure. - + Args: config: The ribbon configuration dictionary - + Returns: List of validation error messages (empty if valid) """ errors = [] - + if not isinstance(config, dict): errors.append("Config must be a dictionary") return errors - + for tab_name, tab_data in config.items(): if not isinstance(tab_data, dict): errors.append(f"Tab '{tab_name}' data must be a dictionary") continue - - if 'groups' not in tab_data: + + if "groups" not in tab_data: errors.append(f"Tab '{tab_name}' missing 'groups' key") continue - - groups = tab_data['groups'] + + groups = tab_data["groups"] if not isinstance(groups, list): errors.append(f"Tab '{tab_name}' groups must be a list") continue - + for i, group in enumerate(groups): if not isinstance(group, dict): errors.append(f"Tab '{tab_name}' group {i} must be a dictionary") continue - - if 'name' not in group: + + if "name" not in group: errors.append(f"Tab '{tab_name}' group {i} missing 'name'") - - if 'actions' not in group: + + if "actions" not in group: errors.append(f"Tab '{tab_name}' group {i} missing 'actions'") continue - - actions = group['actions'] + + actions = group["actions"] if not isinstance(actions, list): errors.append(f"Tab '{tab_name}' group {i} actions must be a list") continue - + for j, action in enumerate(actions): if not isinstance(action, dict): errors.append(f"Tab '{tab_name}' group {i} action {j} must be a dictionary") continue - - required_keys = ['label', 'action', 'tooltip'] + + required_keys = ["label", "action", "tooltip"] for key in required_keys: if key not in action: - errors.append( - f"Tab '{tab_name}' group {i} action {j} missing '{key}'" - ) - + errors.append(f"Tab '{tab_name}' group {i} action {j} missing '{key}'") + return errors def print_ribbon_summary(config: Dict[str, Any]): """ Print a summary of the ribbon configuration. - + Args: config: The ribbon configuration dictionary """ print("\n=== Ribbon Configuration Summary ===\n") - + total_tabs = len(config) - total_groups = sum(len(tab_data['groups']) for tab_data in config.values()) - total_actions = sum( - len(group['actions']) - for tab_data in config.values() - for group in tab_data['groups'] - ) - + total_groups = sum(len(tab_data["groups"]) for tab_data in config.values()) + total_actions = sum(len(group["actions"]) for tab_data in config.values() for group in tab_data["groups"]) + print(f"Total Tabs: {total_tabs}") print(f"Total Groups: {total_groups}") print(f"Total Actions: {total_actions}\n") - + for tab_name, tab_data in config.items(): print(f"📑 {tab_name}") - for group in tab_data['groups']: + for group in tab_data["groups"]: print(f" 📦 {group['name']} ({len(group['actions'])} actions)") - for action in group['actions']: - shortcut = f" ({action['shortcut']})" if action.get('shortcut') else "" + for action in group["actions"]: + shortcut = f" ({action['shortcut']})" if action.get("shortcut") else "" print(f" • {action['label']}{shortcut}") print() diff --git a/pyPhotoAlbum/ribbon_widget.py b/pyPhotoAlbum/ribbon_widget.py index 72d46a3..2f6067a 100644 --- a/pyPhotoAlbum/ribbon_widget.py +++ b/pyPhotoAlbum/ribbon_widget.py @@ -17,10 +17,11 @@ class RibbonWidget(QWidget): # Use provided config or fall back to importing the old one if ribbon_config is None: from ribbon_config import RIBBON_CONFIG + self.ribbon_config = RIBBON_CONFIG else: self.ribbon_config = ribbon_config - + # Main layout main_layout = QVBoxLayout() main_layout.setContentsMargins(0, 0, 0, 0) diff --git a/pyPhotoAlbum/snapping.py b/pyPhotoAlbum/snapping.py index 5f37be3..6ea762d 100644 --- a/pyPhotoAlbum/snapping.py +++ b/pyPhotoAlbum/snapping.py @@ -11,28 +11,24 @@ from dataclasses import dataclass @dataclass class Guide: """Represents a snapping guide (vertical or horizontal line)""" + position: float # Position in mm orientation: str # 'vertical' or 'horizontal' def serialize(self) -> dict: """Serialize guide to dictionary""" - return { - "position": self.position, - "orientation": self.orientation - } + return {"position": self.position, "orientation": self.orientation} @staticmethod - def deserialize(data: dict) -> 'Guide': + def deserialize(data: dict) -> "Guide": """Deserialize guide from dictionary""" - return Guide( - position=data.get("position", 0), - orientation=data.get("orientation", "vertical") - ) + return Guide(position=data.get("position", 0), orientation=data.get("orientation", "vertical")) @dataclass class SnapResizeParams: """Parameters for snap resize operations""" + position: Tuple[float, float] size: Tuple[float, float] dx: float @@ -45,11 +41,11 @@ class SnapResizeParams: class SnappingSystem: """Manages snapping behavior for layout elements""" - + def __init__(self, snap_threshold_mm: float = 5.0): """ Initialize snapping system - + Args: snap_threshold_mm: Distance in mm within which snapping occurs """ @@ -59,28 +55,30 @@ class SnappingSystem: self.snap_to_edges = True self.snap_to_guides = True self.guides: List[Guide] = [] - + def add_guide(self, position: float, orientation: str): """Add a new guide""" guide = Guide(position=position, orientation=orientation) self.guides.append(guide) return guide - + def remove_guide(self, guide: Guide): """Remove a guide""" if guide in self.guides: self.guides.remove(guide) - + def clear_guides(self): """Remove all guides""" self.guides.clear() - - def snap_position(self, - position: Tuple[float, float], - size: Tuple[float, float], - page_size: Tuple[float, float], - dpi: int = 300, - project=None) -> Tuple[float, float]: + + def snap_position( + self, + position: Tuple[float, float], + size: Tuple[float, float], + page_size: Tuple[float, float], + dpi: int = 300, + project=None, + ) -> Tuple[float, float]: """ Apply snapping to a position using combined distance threshold @@ -114,43 +112,47 @@ class SnappingSystem: # Convert threshold from mm to pixels snap_threshold_px = snap_threshold_mm * dpi / 25.4 - + # Collect all potential snap points for both edges of the element snap_points = [] - + # 1. Page edge snap points if snap_to_edges: page_width_px = page_width_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4 # Corners where element's top-left can snap - snap_points.extend([ - (0, 0), # Top-left corner - (page_width_px - width, 0), # Top-right corner - (0, page_height_px - height), # Bottom-left corner - (page_width_px - width, page_height_px - height), # Bottom-right corner - ]) + snap_points.extend( + [ + (0, 0), # Top-left corner + (page_width_px - width, 0), # Top-right corner + (0, page_height_px - height), # Bottom-left corner + (page_width_px - width, page_height_px - height), # Bottom-right corner + ] + ) # Edge positions (element aligned to edge on one axis) - snap_points.extend([ - (0, y), # Left edge - (page_width_px - width, y), # Right edge - (x, 0), # Top edge - (x, page_height_px - height), # Bottom edge - ]) + snap_points.extend( + [ + (0, y), # Left edge + (page_width_px - width, y), # Right edge + (x, 0), # Top edge + (x, page_height_px - height), # Bottom edge + ] + ) # 2. Grid snap points if snap_to_grid: grid_size_px = grid_size_mm * dpi / 25.4 page_width_px = page_width_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4 - + # Calculate grid intersection points within range x_start = max(0, int((x - snap_threshold_px) / grid_size_px)) * grid_size_px x_end = min(page_width_px, int((x + snap_threshold_px) / grid_size_px + 1) * grid_size_px) y_start = max(0, int((y - snap_threshold_px) / grid_size_px)) * grid_size_px y_end = min(page_height_px, int((y + snap_threshold_px) / grid_size_px + 1) * grid_size_px) - + grid_x = x_start while grid_x <= x_end: grid_y = y_start @@ -163,12 +165,12 @@ class SnappingSystem: snap_points.append((grid_x, grid_y - height)) grid_y += grid_size_px grid_x += grid_size_px - + # 3. Guide snap points if snap_to_guides: - vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'vertical'] - horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'horizontal'] - + vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == "vertical"] + horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == "horizontal"] + # Guide intersections (when both vertical and horizontal guides exist) for vg in vertical_guides: for hg in horizontal_guides: @@ -177,23 +179,23 @@ class SnappingSystem: snap_points.append((vg - width, hg)) snap_points.append((vg, hg - height)) snap_points.append((vg - width, hg - height)) - + # Find the nearest snap point using Euclidean distance best_snap_point = None best_distance = snap_threshold_px - + for snap_x, snap_y in snap_points: distance = math.sqrt((x - snap_x) ** 2 + (y - snap_y) ** 2) if distance < best_distance: best_snap_point = (snap_x, snap_y) best_distance = distance - + # Return snapped position or original position if best_snap_point: return best_snap_point else: return (x, y) - + def snap_resize(self, params: SnapResizeParams) -> Tuple[Tuple[float, float], Tuple[float, float]]: """ Apply snapping during resize operations @@ -222,32 +224,32 @@ class SnappingSystem: new_width, new_height = width, height # Apply resize based on handle - if params.resize_handle in ['nw', 'n', 'ne']: + if params.resize_handle in ["nw", "n", "ne"]: # Top edge moving new_y = y + params.dy new_height = height - params.dy - if params.resize_handle in ['sw', 's', 'se']: + if params.resize_handle in ["sw", "s", "se"]: # Bottom edge moving new_height = height + params.dy - if params.resize_handle in ['nw', 'w', 'sw']: + if params.resize_handle in ["nw", "w", "sw"]: # Left edge moving new_x = x + params.dx new_width = width - params.dx - if params.resize_handle in ['ne', 'e', 'se']: + if params.resize_handle in ["ne", "e", "se"]: # Right edge moving new_width = width + params.dx - + # Now apply snapping to the edges that are being moved # Use _snap_edge_to_targets consistently for all edges # Snap left edge (for nw, w, sw handles) - if params.resize_handle in ['nw', 'w', 'sw']: + if params.resize_handle in ["nw", "w", "sw"]: # Try to snap the left edge snapped_left = self._snap_edge_to_targets( - new_x, page_width_mm, params.dpi, snap_threshold_px, 'vertical', params.project + new_x, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project ) if snapped_left is not None: # Adjust width to compensate for position change @@ -256,21 +258,21 @@ class SnappingSystem: new_width += width_adjustment # Snap right edge (for ne, e, se handles) - if params.resize_handle in ['ne', 'e', 'se']: + if params.resize_handle in ["ne", "e", "se"]: # Calculate right edge position right_edge = new_x + new_width # Try to snap the right edge snapped_right = self._snap_edge_to_targets( - right_edge, page_width_mm, params.dpi, snap_threshold_px, 'vertical', params.project + right_edge, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project ) if snapped_right is not None: new_width = snapped_right - new_x # Snap top edge (for nw, n, ne handles) - if params.resize_handle in ['nw', 'n', 'ne']: + if params.resize_handle in ["nw", "n", "ne"]: # Try to snap the top edge snapped_top = self._snap_edge_to_targets( - new_y, page_height_mm, params.dpi, snap_threshold_px, 'horizontal', params.project + new_y, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project ) if snapped_top is not None: # Adjust height to compensate for position change @@ -279,30 +281,32 @@ class SnappingSystem: new_height += height_adjustment # Snap bottom edge (for sw, s, se handles) - if params.resize_handle in ['sw', 's', 'se']: + if params.resize_handle in ["sw", "s", "se"]: # Calculate bottom edge position bottom_edge = new_y + new_height # Try to snap the bottom edge snapped_bottom = self._snap_edge_to_targets( - bottom_edge, page_height_mm, params.dpi, snap_threshold_px, 'horizontal', params.project + bottom_edge, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project ) if snapped_bottom is not None: new_height = snapped_bottom - new_y - + # Ensure minimum size min_size = 10 # Minimum 10 pixels new_width = max(new_width, min_size) new_height = max(new_height, min_size) - + return ((new_x, new_y), (new_width, new_height)) - - def _snap_edge_to_targets(self, - edge_position: float, - page_size_mm: float, - dpi: int, - snap_threshold_px: float, - orientation: str, - project=None) -> Optional[float]: + + def _snap_edge_to_targets( + self, + edge_position: float, + page_size_mm: float, + dpi: int, + snap_threshold_px: float, + orientation: str, + project=None, + ) -> Optional[float]: """ Snap an edge position to available targets (grid, edges, guides) @@ -354,28 +358,24 @@ class SnappingSystem: if guide.orientation == orientation: guide_pos_px = guide.position * dpi / 25.4 snap_candidates.append((guide_pos_px, abs(edge_position - guide_pos_px))) - + # Find the best snap candidate within threshold best_snap = None best_distance = snap_threshold_px - + for snap_pos, distance in snap_candidates: if distance < best_distance: best_snap = snap_pos best_distance = distance - + return best_snap - - def _snap_axis(self, - position: float, - size: float, - page_size_mm: float, - dpi: int, - snap_threshold_px: float, - orientation: str) -> float: + + def _snap_axis( + self, position: float, size: float, page_size_mm: float, dpi: int, snap_threshold_px: float, orientation: str + ) -> float: """ Snap along a single axis - + Args: position: Current position along axis in pixels size: Element size along axis in pixels @@ -383,115 +383,108 @@ class SnappingSystem: dpi: DPI for conversion snap_threshold_px: Snap threshold in pixels orientation: 'vertical' for x-axis, 'horizontal' for y-axis - + Returns: Snapped position in pixels """ snap_candidates = [] - + # 1. Page edge snapping if self.snap_to_edges: # Snap to start edge (0) snap_candidates.append((0, abs(position - 0))) - + # Snap to end edge page_size_px = page_size_mm * dpi / 25.4 snap_candidates.append((page_size_px - size, abs(position - (page_size_px - size)))) - + # Also snap element's far edge to page edge snap_candidates.append((page_size_px - size, abs((position + size) - page_size_px))) - + # 2. Grid snapping if self.snap_to_grid: grid_size_px = self.grid_size_mm * dpi / 25.4 - + # Snap to nearest grid line nearest_grid = round(position / grid_size_px) * grid_size_px snap_candidates.append((nearest_grid, abs(position - nearest_grid))) - + # Also try snapping element's far edge to grid element_end = position + size nearest_grid_end = round(element_end / grid_size_px) * grid_size_px snap_candidates.append((nearest_grid_end - size, abs(element_end - nearest_grid_end))) - + # 3. Guide snapping if self.snap_to_guides: for guide in self.guides: if guide.orientation == orientation: guide_pos_px = guide.position * dpi / 25.4 - + # Snap start edge to guide snap_candidates.append((guide_pos_px, abs(position - guide_pos_px))) - + # Snap end edge to guide element_end = position + size snap_candidates.append((guide_pos_px - size, abs(element_end - guide_pos_px))) - + # Find the best snap candidate within threshold best_snap = None best_distance = snap_threshold_px - + for snap_pos, distance in snap_candidates: if distance < best_distance: best_snap = snap_pos best_distance = distance - + return best_snap if best_snap is not None else position - + def get_snap_lines(self, page_size: Tuple[float, float], dpi: int = 300) -> dict: """ Get all snap lines for visualization - + Args: page_size: Page size (width, height) in mm dpi: DPI for conversion - + Returns: Dictionary with 'grid', 'edges', and 'guides' lists """ page_width_mm, page_height_mm = page_size page_width_px = page_width_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4 - - result = { - 'grid': [], - 'edges': [], - 'guides': [] - } - + + result = {"grid": [], "edges": [], "guides": []} + # Grid lines if self.snap_to_grid: grid_size_px = self.grid_size_mm * dpi / 25.4 - + # Vertical grid lines x = 0 while x <= page_width_px: - result['grid'].append(('vertical', x)) + result["grid"].append(("vertical", x)) x += grid_size_px - + # Horizontal grid lines y = 0 while y <= page_height_px: - result['grid'].append(('horizontal', y)) + result["grid"].append(("horizontal", y)) y += grid_size_px - + # Edge lines if self.snap_to_edges: - result['edges'].extend([ - ('vertical', 0), - ('vertical', page_width_px), - ('horizontal', 0), - ('horizontal', page_height_px) - ]) - + result["edges"].extend( + [("vertical", 0), ("vertical", page_width_px), ("horizontal", 0), ("horizontal", page_height_px)] + ) + # Guide lines if self.snap_to_guides: for guide in self.guides: guide_pos_px = guide.position * dpi / 25.4 - result['guides'].append((guide.orientation, guide_pos_px)) - + result["guides"].append((guide.orientation, guide_pos_px)) + return result - + def serialize(self) -> dict: """Serialize snapping system to dictionary""" return { @@ -500,9 +493,9 @@ class SnappingSystem: "snap_to_grid": self.snap_to_grid, "snap_to_edges": self.snap_to_edges, "snap_to_guides": self.snap_to_guides, - "guides": [guide.serialize() for guide in self.guides] + "guides": [guide.serialize() for guide in self.guides], } - + def deserialize(self, data: dict): """Deserialize from dictionary""" self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0) @@ -510,7 +503,7 @@ class SnappingSystem: self.snap_to_grid = data.get("snap_to_grid", False) self.snap_to_edges = data.get("snap_to_edges", True) self.snap_to_guides = data.get("snap_to_guides", True) - + self.guides = [] for guide_data in data.get("guides", []): self.guides.append(Guide.deserialize(guide_data)) diff --git a/pyPhotoAlbum/template_manager.py b/pyPhotoAlbum/template_manager.py index bfbacd2..c109fc2 100644 --- a/pyPhotoAlbum/template_manager.py +++ b/pyPhotoAlbum/template_manager.py @@ -14,7 +14,9 @@ from pyPhotoAlbum.project import Page class Template: """Class representing a page layout template""" - def __init__(self, name: str = "Untitled Template", description: str = "", page_size_mm: Tuple[float, float] = (210, 297)): + def __init__( + self, name: str = "Untitled Template", description: str = "", page_size_mm: Tuple[float, float] = (210, 297) + ): self.name = name self.description = description self.page_size_mm = page_size_mm @@ -30,16 +32,16 @@ class Template: "name": self.name, "description": self.description, "page_size_mm": self.page_size_mm, - "elements": [elem.serialize() for elem in self.elements] + "elements": [elem.serialize() for elem in self.elements], } @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'Template': + def from_dict(cls, data: Dict[str, Any]) -> "Template": """Deserialize template from dictionary""" template = cls( name=data.get("name", "Untitled Template"), description=data.get("description", ""), - page_size_mm=tuple(data.get("page_size_mm", (210, 297))) + page_size_mm=tuple(data.get("page_size_mm", (210, 297))), ) # Deserialize elements @@ -59,13 +61,13 @@ class Template: def save_to_file(self, file_path: str): """Save template to JSON file""" - with open(file_path, 'w') as f: + with open(file_path, "w") as f: json.dump(self.to_dict(), f, indent=2) @classmethod - def load_from_file(cls, file_path: str) -> 'Template': + def load_from_file(cls, file_path: str) -> "Template": """Load template from JSON file""" - with open(file_path, 'r') as f: + with open(file_path, "r") as f: data = json.load(f) return cls.from_dict(data) @@ -94,7 +96,7 @@ class TemplateManager: def _ensure_templates_directory(self): """Create templates directory if it doesn't exist""" self.templates_dir.mkdir(parents=True, exist_ok=True) - + # Also ensure built-in templates directory exists builtin_dir = self._get_builtin_templates_directory() builtin_dir.mkdir(parents=True, exist_ok=True) @@ -102,33 +104,33 @@ class TemplateManager: def list_templates(self) -> List[str]: """List all available template names (embedded + user + built-in)""" templates = [] - + # List embedded templates (priority) if self.project and self.project.embedded_templates: for template_name in self.project.embedded_templates.keys(): templates.append(f"[Embedded] {template_name}") - + # List user templates if self.templates_dir.exists(): for file in self.templates_dir.glob("*.json"): templates.append(file.stem) - + # List built-in templates builtin_dir = self._get_builtin_templates_directory() if builtin_dir.exists(): for file in builtin_dir.glob("*.json"): template_name = f"[Built-in] {file.stem}" templates.append(template_name) - + return sorted(templates) def load_template(self, name: str) -> Template: """ Load a template by name with priority: embedded > user > built-in. - + Args: name: Template name (may include prefix like '[Embedded]' or '[Built-in]') - + Returns: Template instance """ @@ -139,12 +141,12 @@ class TemplateManager: template_data = self.project.embedded_templates[actual_name] return Template.from_dict(template_data) raise FileNotFoundError(f"Embedded template '{actual_name}' not found") - + # Check embedded templates even without prefix (for backward compatibility) if self.project and name in self.project.embedded_templates: template_data = self.project.embedded_templates[name] return Template.from_dict(template_data) - + # Check if it's a built-in template if name.startswith("[Built-in] "): actual_name = name.replace("[Built-in] ", "") @@ -152,16 +154,16 @@ class TemplateManager: else: # User template template_path = self.templates_dir / f"{name}.json" - + if not template_path.exists(): raise FileNotFoundError(f"Template '{name}' not found") - + return Template.load_from_file(str(template_path)) def save_template(self, template: Template, embed_in_project: bool = False): """ Save a template to filesystem or embed in project. - + Args: template: Template to save embed_in_project: If True, embed in project instead of saving to filesystem @@ -179,7 +181,7 @@ class TemplateManager: """Delete a template (embedded or user templates only)""" if name.startswith("[Built-in] "): raise PermissionError("Cannot delete built-in templates") - + # Check if it's an embedded template if name.startswith("[Embedded] "): actual_name = name.replace("[Embedded] ", "") @@ -188,7 +190,7 @@ class TemplateManager: print(f"Removed embedded template '{actual_name}'") return raise FileNotFoundError(f"Embedded template '{actual_name}' not found") - + # User template from filesystem template_path = self.templates_dir / f"{name}.json" if template_path.exists(): @@ -197,26 +199,22 @@ class TemplateManager: def embed_template(self, template: Template): """ Embed a template in the project. - + Args: template: Template to embed """ if not self.project: raise RuntimeError("No project associated with this TemplateManager") - + self.project.embedded_templates[template.name] = template.to_dict() print(f"Embedded template '{template.name}' in project") - + def create_template_from_page(self, page: Page, name: str, description: str = "") -> Template: """ Create a template from an existing page. Converts all ImageData elements to PlaceholderData. """ - template = Template( - name=name, - description=description, - page_size_mm=page.layout.size - ) + template = Template(name=name, description=description, page_size_mm=page.layout.size) # Convert elements for element in page.layout.elements: @@ -229,7 +227,7 @@ class TemplateManager: width=element.size[0], height=element.size[1], rotation=element.rotation, - z_index=element.z_index + z_index=element.z_index, ) template.add_element(placeholder) elif isinstance(element, TextBoxData): @@ -243,7 +241,7 @@ class TemplateManager: width=element.size[0], height=element.size[1], rotation=element.rotation, - z_index=element.z_index + z_index=element.z_index, ) template.add_element(text_box) elif isinstance(element, PlaceholderData): @@ -256,7 +254,7 @@ class TemplateManager: width=element.size[0], height=element.size[1], rotation=element.rotation, - z_index=element.z_index + z_index=element.z_index, ) template.add_element(placeholder) @@ -268,32 +266,32 @@ class TemplateManager: from_size: Tuple[float, float], to_size: Tuple[float, float], scale_mode: str = "proportional", - margin_percent: float = 0.0 + margin_percent: float = 0.0, ) -> List[BaseLayoutElement]: """ Scale template elements to fit target page size with adjustable margins. - + Args: elements: List of elements to scale from_size: Original template size (width, height) in mm to_size: Target page size (width, height) in mm scale_mode: "proportional", "stretch", or "center" margin_percent: Percentage of page size to use for margins (0-10%) - + Returns: List of scaled elements """ from_width, from_height = from_size to_width, to_height = to_size - + # Calculate target margins from percentage margin_x = to_width * (margin_percent / 100.0) margin_y = to_height * (margin_percent / 100.0) - + # Available content area after margins content_width = to_width - (2 * margin_x) content_height = to_height - (2 * margin_y) - + # Calculate scale factors based on mode if scale_mode == "stretch": # Stretch to fill content area independently in each dimension @@ -323,14 +321,13 @@ class TemplateManager: # Create a new element of the same type if isinstance(element, PlaceholderData): new_elem = PlaceholderData( - placeholder_type=element.placeholder_type, - default_content=element.default_content + placeholder_type=element.placeholder_type, default_content=element.default_content ) elif isinstance(element, TextBoxData): new_elem = TextBoxData( text_content=element.text_content, font_settings=element.font_settings.copy() if element.font_settings else None, - alignment=element.alignment + alignment=element.alignment, ) else: continue # Skip other types @@ -339,14 +336,8 @@ class TemplateManager: old_x, old_y = element.position old_w, old_h = element.size - new_elem.position = ( - old_x * scale_x + offset_x, - old_y * scale_y + offset_y - ) - new_elem.size = ( - old_w * scale_x, - old_h * scale_y - ) + new_elem.position = (old_x * scale_x + offset_x, old_y * scale_y + offset_y) + new_elem.size = (old_w * scale_x, old_h * scale_y) new_elem.rotation = element.rotation new_elem.z_index = element.z_index @@ -362,15 +353,9 @@ class TemplateManager: for elem in scaled_elements: # Convert position from mm to pixels - elem.position = ( - elem.position[0] * mm_to_px, - elem.position[1] * mm_to_px - ) + elem.position = (elem.position[0] * mm_to_px, elem.position[1] * mm_to_px) # Convert size from mm to pixels - elem.size = ( - elem.size[0] * mm_to_px, - elem.size[1] * mm_to_px - ) + elem.size = (elem.size[0] * mm_to_px, elem.size[1] * mm_to_px) return scaled_elements @@ -381,15 +366,15 @@ class TemplateManager: mode: str = "replace", scale_mode: str = "proportional", margin_percent: float = 2.5, - auto_embed: bool = True + auto_embed: bool = True, ): """ Apply template to an existing page with adjustable margins. - + Args: template: Template to apply page: Target page - mode: "replace" to clear page and add placeholders, + mode: "replace" to clear page and add placeholders, "reflow" to keep existing content and reposition scale_mode: "proportional", "stretch", or "center" margin_percent: Percentage of page size to use for margins (0-10%) @@ -399,44 +384,36 @@ class TemplateManager: if auto_embed and self.project: if template.name not in self.project.embedded_templates: self.embed_template(template) - + if mode == "replace": # Clear existing elements page.layout.elements.clear() - + # Scale template elements to fit page scaled_elements = self.scale_template_elements( - template.elements, - template.page_size_mm, - page.layout.size, - scale_mode, - margin_percent + template.elements, template.page_size_mm, page.layout.size, scale_mode, margin_percent ) - + # Add scaled elements to page for element in scaled_elements: page.layout.add_element(element) - + elif mode == "reflow": # Keep existing content but reposition to template slots existing_images = [e for e in page.layout.elements if isinstance(e, ImageData)] existing_text = [e for e in page.layout.elements if isinstance(e, TextBoxData)] - + # Get template placeholders (scaled) scaled_elements = self.scale_template_elements( - template.elements, - template.page_size_mm, - page.layout.size, - scale_mode, - margin_percent + template.elements, template.page_size_mm, page.layout.size, scale_mode, margin_percent ) - + template_placeholders = [e for e in scaled_elements if isinstance(e, PlaceholderData)] template_text = [e for e in scaled_elements if isinstance(e, TextBoxData)] - + # Clear page page.layout.elements.clear() - + # Reflow images into placeholder slots for i, placeholder in enumerate(template_placeholders): if i < len(existing_images): @@ -449,11 +426,11 @@ class TemplateManager: else: # Add placeholder if no more images page.layout.add_element(placeholder) - + # Add remaining images (if any) at their original positions - for img in existing_images[len(template_placeholders):]: + for img in existing_images[len(template_placeholders) :]: page.layout.add_element(img) - + # Add template text boxes for text_elem in template_text: page.layout.add_element(text_elem) @@ -465,7 +442,7 @@ class TemplateManager: target_size_mm: Optional[Tuple[float, float]] = None, scale_mode: str = "proportional", margin_percent: float = 2.5, - auto_embed: bool = True + auto_embed: bool = True, ) -> Page: """ Create a new page from a template. @@ -494,11 +471,7 @@ class TemplateManager: page_size = target_size_mm # Scale template elements with margins elements = self.scale_template_elements( - template.elements, - template.page_size_mm, - target_size_mm, - scale_mode, - margin_percent + template.elements, template.page_size_mm, target_size_mm, scale_mode, margin_percent ) # Create new page layout diff --git a/pyPhotoAlbum/text_edit_dialog.py b/pyPhotoAlbum/text_edit_dialog.py index 34edfe8..df74fe4 100644 --- a/pyPhotoAlbum/text_edit_dialog.py +++ b/pyPhotoAlbum/text_edit_dialog.py @@ -3,8 +3,15 @@ Text editing dialog for pyPhotoAlbum """ from PyQt6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QPushButton, - QTextEdit, QLabel, QComboBox, QSpinBox, QColorDialog + QDialog, + QVBoxLayout, + QHBoxLayout, + QPushButton, + QTextEdit, + QLabel, + QComboBox, + QSpinBox, + QColorDialog, ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont, QColor @@ -12,58 +19,57 @@ from PyQt6.QtGui import QFont, QColor class TextEditDialog(QDialog): """Dialog for editing text box content and properties""" - + def __init__(self, text_element, parent=None): super().__init__(parent) self.text_element = text_element self.setWindowTitle("Edit Text") self.resize(500, 400) - + # Create UI self._init_ui() - + # Load current values self._load_values() - + def _init_ui(self): """Initialize the user interface""" layout = QVBoxLayout() - + # Text editor text_label = QLabel("Text:") self.text_edit = QTextEdit() self.text_edit.setAcceptRichText(False) # Plain text only layout.addWidget(text_label) layout.addWidget(self.text_edit) - + # Font settings font_layout = QHBoxLayout() - + # Font family font_layout.addWidget(QLabel("Font:")) self.font_combo = QComboBox() - self.font_combo.addItems([ - "Arial", "Times New Roman", "Courier New", - "Helvetica", "Verdana", "Georgia", "Comic Sans MS" - ]) + self.font_combo.addItems( + ["Arial", "Times New Roman", "Courier New", "Helvetica", "Verdana", "Georgia", "Comic Sans MS"] + ) font_layout.addWidget(self.font_combo) - + # Font size font_layout.addWidget(QLabel("Size:")) self.font_size_spin = QSpinBox() self.font_size_spin.setRange(6, 72) self.font_size_spin.setValue(12) font_layout.addWidget(self.font_size_spin) - + # Text color self.color_button = QPushButton("Color") self.color_button.clicked.connect(self._choose_color) self.current_color = QColor(0, 0, 0) # Default black font_layout.addWidget(self.color_button) - + font_layout.addStretch() layout.addLayout(font_layout) - + # Alignment alignment_layout = QHBoxLayout() alignment_layout.addWidget(QLabel("Alignment:")) @@ -72,84 +78,76 @@ class TextEditDialog(QDialog): alignment_layout.addWidget(self.alignment_combo) alignment_layout.addStretch() layout.addLayout(alignment_layout) - + # Buttons button_layout = QHBoxLayout() button_layout.addStretch() - + cancel_button = QPushButton("Cancel") cancel_button.clicked.connect(self.reject) button_layout.addWidget(cancel_button) - + ok_button = QPushButton("OK") ok_button.clicked.connect(self.accept) ok_button.setDefault(True) button_layout.addWidget(ok_button) - + layout.addLayout(button_layout) - + self.setLayout(layout) - + def _load_values(self): """Load current values from text element""" # Load text content self.text_edit.setPlainText(self.text_element.text_content) - + # Load font settings - font_family = self.text_element.font_settings.get('family', 'Arial') + font_family = self.text_element.font_settings.get("family", "Arial") index = self.font_combo.findText(font_family) if index >= 0: self.font_combo.setCurrentIndex(index) - - font_size = self.text_element.font_settings.get('size', 12) + + font_size = self.text_element.font_settings.get("size", 12) self.font_size_spin.setValue(int(font_size)) - + # Load color - color = self.text_element.font_settings.get('color', (0, 0, 0)) + color = self.text_element.font_settings.get("color", (0, 0, 0)) if all(isinstance(c, int) and c > 1 for c in color): # Color in 0-255 range self.current_color = QColor(*color) else: # Color in 0-1 range - self.current_color = QColor( - int(color[0] * 255), - int(color[1] * 255), - int(color[2] * 255) - ) + self.current_color = QColor(int(color[0] * 255), int(color[1] * 255), int(color[2] * 255)) self._update_color_button() - + # Load alignment alignment = self.text_element.alignment index = self.alignment_combo.findText(alignment) if index >= 0: self.alignment_combo.setCurrentIndex(index) - + def _choose_color(self): """Open color picker dialog""" color = QColorDialog.getColor(self.current_color, self, "Choose Text Color") if color.isValid(): self.current_color = color self._update_color_button() - + def _update_color_button(self): """Update color button appearance""" self.color_button.setStyleSheet( f"background-color: {self.current_color.name()}; " f"color: {'white' if self.current_color.lightness() < 128 else 'black'};" ) - + def get_values(self): """Get the edited values""" return { - 'text_content': self.text_edit.toPlainText(), - 'font_settings': { - 'family': self.font_combo.currentText(), - 'size': self.font_size_spin.value(), - 'color': ( - self.current_color.red(), - self.current_color.green(), - self.current_color.blue() - ) + "text_content": self.text_edit.toPlainText(), + "font_settings": { + "family": self.font_combo.currentText(), + "size": self.font_size_spin.value(), + "color": (self.current_color.red(), self.current_color.green(), self.current_color.blue()), }, - 'alignment': self.alignment_combo.currentText() + "alignment": self.alignment_combo.currentText(), } diff --git a/pyPhotoAlbum/version_manager.py b/pyPhotoAlbum/version_manager.py index 84b073e..9062d84 100644 --- a/pyPhotoAlbum/version_manager.py +++ b/pyPhotoAlbum/version_manager.py @@ -24,7 +24,7 @@ VERSION_HISTORY = { "released": "2025-01-11", "breaking_changes": [ "Asset paths changed from absolute/full-project-relative to project-relative", - "Added automatic path normalization for legacy projects" + "Added automatic path normalization for legacy projects", ], "compatible_with": ["1.0", "2.0"], # 2.0 can read 1.0 with migration }, @@ -38,7 +38,7 @@ VERSION_HISTORY = { "Added deletion tracking (deleted flag and deleted_at timestamp)", ], "compatible_with": ["1.0", "2.0", "3.0"], # 3.0 can read older versions with migration - } + }, } @@ -120,9 +120,11 @@ class DataMigration: @classmethod def register_migration(cls, from_version: str, to_version: str): """Decorator to register a migration function""" + def decorator(func): cls._migrations[(from_version, to_version)] = func return func + return decorator @classmethod @@ -164,6 +166,7 @@ class DataMigration: # Register migrations + @DataMigration.register_migration("1.0", "2.0") def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]: """ @@ -176,7 +179,7 @@ def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]: print("Migration 1.0 → 2.0: Asset paths will be normalized during load") # Update version in data - data['data_version'] = "2.0" + data["data_version"] = "2.0" # Note: Actual path normalization is handled in load_from_zip # This migration mainly updates the version number @@ -249,7 +252,7 @@ def migrate_2_0_to_3_0(data: Dict[str, Any]) -> Dict[str, Any]: element_data["deleted_at"] = None # Update version - data['data_version'] = "3.0" + data["data_version"] = "3.0" print(f" Migrated {len(data.get('pages', []))} pages to v3.0") @@ -287,7 +290,7 @@ def check_version_compatibility(file_version: str, file_path: str = "") -> tuple error_msg += f"File version info:\n" error_msg += f" Description: {file_info.get('description', 'Unknown')}\n" error_msg += f" Released: {file_info.get('released', 'Unknown')}\n" - breaking_changes = file_info.get('breaking_changes', []) + breaking_changes = file_info.get("breaking_changes", []) if breaking_changes: error_msg += f" Breaking changes:\n" for change in breaking_changes: @@ -312,7 +315,7 @@ def format_version_info() -> str: info.append(f" Description: {version_info.get('description', 'Unknown')}") info.append(f" Released: {version_info.get('released', 'Unknown')}") - breaking_changes = version_info.get('breaking_changes', []) + breaking_changes = version_info.get("breaking_changes", []) if breaking_changes: info.append(f" Breaking changes:") for change in breaking_changes: diff --git a/tests/conftest.py b/tests/conftest.py index 801c5f3..20b4b39 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,9 +15,9 @@ from pyPhotoAlbum.project import Project, Page @pytest.fixture def temp_image_file(): """Create a temporary test image file""" - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: # Create a simple test image - img = Image.new('RGB', (100, 100), color='red') + img = Image.new("RGB", (100, 100), color="red") img.save(f.name) yield f.name # Cleanup @@ -37,37 +37,19 @@ def temp_dir(): @pytest.fixture def sample_image_data(temp_image_file): """Create a sample ImageData instance""" - return ImageData( - image_path=temp_image_file, - x=10.0, - y=20.0, - width=100.0, - height=150.0 - ) + return ImageData(image_path=temp_image_file, x=10.0, y=20.0, width=100.0, height=150.0) @pytest.fixture def sample_placeholder_data(): """Create a sample PlaceholderData instance""" - return PlaceholderData( - placeholder_type="image", - x=50.0, - y=60.0, - width=200.0, - height=150.0 - ) + return PlaceholderData(placeholder_type="image", x=50.0, y=60.0, width=200.0, height=150.0) @pytest.fixture def sample_textbox_data(): """Create a sample TextBoxData instance""" - return TextBoxData( - text_content="Sample Text", - x=30.0, - y=40.0, - width=150.0, - height=50.0 - ) + return TextBoxData(text_content="Sample Text", x=30.0, y=40.0, width=150.0, height=50.0) @pytest.fixture @@ -119,10 +101,7 @@ def mock_main_window(): window.project = Project(name="Test Project") # Add a test page - page = Page( - layout=PageLayout(width=210, height=297), # A4 size in mm - page_number=1 - ) + page = Page(layout=PageLayout(width=210, height=297), page_number=1) # A4 size in mm window.project.pages.append(page) window.project.working_dpi = 96 window.project.page_size_mm = (210, 297) @@ -139,39 +118,19 @@ def mock_main_window(): @pytest.fixture def sample_image_element(): """Create a sample ImageData element for testing""" - return ImageData( - image_path="test.jpg", - x=100, - y=100, - width=200, - height=150, - z_index=1 - ) + return ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150, z_index=1) @pytest.fixture def sample_placeholder_element(): """Create a sample PlaceholderData element for testing""" - return PlaceholderData( - x=50, - y=50, - width=100, - height=100, - z_index=0 - ) + return PlaceholderData(x=50, y=50, width=100, height=100, z_index=0) @pytest.fixture def sample_textbox_element(): """Create a sample TextBoxData element for testing""" - return TextBoxData( - x=10, - y=10, - width=180, - height=50, - text_content="Test Text", - z_index=2 - ) + return TextBoxData(x=10, y=10, width=180, height=50, text_content="Test Text", z_index=2) @pytest.fixture @@ -189,19 +148,19 @@ def mock_page_renderer(): # Mock coordinate conversion methods def page_to_screen(x, y): - return (renderer.screen_x + x * renderer.zoom, - renderer.screen_y + y * renderer.zoom) + return (renderer.screen_x + x * renderer.zoom, renderer.screen_y + y * renderer.zoom) def screen_to_page(x, y): - return ((x - renderer.screen_x) / renderer.zoom, - (y - renderer.screen_y) / renderer.zoom) + return ((x - renderer.screen_x) / renderer.zoom, (y - renderer.screen_y) / renderer.zoom) def is_point_in_page(x, y): # Simple bounds check (assume 210mm x 297mm page at 96 DPI) page_width_px = 210 * 96 / 25.4 page_height_px = 297 * 96 / 25.4 - return (renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom and - renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom) + return ( + renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom + and renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom + ) renderer.page_to_screen = page_to_screen renderer.screen_to_page = screen_to_page @@ -213,8 +172,8 @@ def mock_page_renderer(): @pytest.fixture def create_mouse_event(): """Factory fixture for creating QMouseEvent objects""" - def _create_event(event_type, x, y, button=Qt.MouseButton.LeftButton, - modifiers=Qt.KeyboardModifier.NoModifier): + + def _create_event(event_type, x, y, button=Qt.MouseButton.LeftButton, modifiers=Qt.KeyboardModifier.NoModifier): """Create a QMouseEvent for testing Args: @@ -224,19 +183,15 @@ def create_mouse_event(): modifiers: Keyboard modifiers """ pos = QPointF(x, y) - return QMouseEvent( - event_type, - pos, - button, - button, - modifiers - ) + return QMouseEvent(event_type, pos, button, button, modifiers) + return _create_event @pytest.fixture def create_wheel_event(): """Factory fixture for creating QWheelEvent objects""" + def _create_event(x, y, delta_y=120, modifiers=Qt.KeyboardModifier.NoModifier): """Create a QWheelEvent for testing @@ -257,38 +212,22 @@ def create_wheel_event(): Qt.MouseButton.NoButton, modifiers, Qt.ScrollPhase.NoScrollPhase, - False + False, ) + return _create_event @pytest.fixture def populated_page(): """Create a page with multiple elements for testing""" - page = Page( - layout=PageLayout(width=210, height=297), - page_number=1 - ) + page = Page(layout=PageLayout(width=210, height=297), page_number=1) # Add various elements - page.layout.add_element(ImageData( - image_path="img1.jpg", - x=10, y=10, - width=100, height=75, - z_index=0 - )) + page.layout.add_element(ImageData(image_path="img1.jpg", x=10, y=10, width=100, height=75, z_index=0)) - page.layout.add_element(PlaceholderData( - x=120, y=10, - width=80, height=60, - z_index=1 - )) + page.layout.add_element(PlaceholderData(x=120, y=10, width=80, height=60, z_index=1)) - page.layout.add_element(TextBoxData( - x=10, y=100, - width=190, height=40, - text_content="Sample Text", - z_index=2 - )) + page.layout.add_element(TextBoxData(x=10, y=100, width=190, height=40, text_content="Sample Text", z_index=2)) return page diff --git a/tests/test_alignment.py b/tests/test_alignment.py index f660d61..31dc115 100755 --- a/tests/test_alignment.py +++ b/tests/test_alignment.py @@ -20,7 +20,7 @@ class TestAlignmentManager: """Test get_bounds with single element""" elem = ImageData(x=10, y=20, width=100, height=50) bounds = AlignmentManager.get_bounds([elem]) - + # min_x, min_y, max_x, max_y assert bounds == (10, 20, 110, 70) @@ -29,9 +29,9 @@ class TestAlignmentManager: elem1 = ImageData(x=10, y=20, width=100, height=50) elem2 = ImageData(x=50, y=10, width=80, height=60) elem3 = ImageData(x=5, y=30, width=90, height=40) - + bounds = AlignmentManager.get_bounds([elem1, elem2, elem3]) - + # min_x = 5, min_y = 10, max_x = 130 (50+80), max_y = 70 (10+60 or 20+50) assert bounds[0] == 5 # min_x assert bounds[1] == 10 # min_y @@ -55,14 +55,14 @@ class TestAlignmentManager: elem1 = ImageData(x=50, y=20, width=100, height=50) elem2 = ImageData(x=30, y=40, width=80, height=60) elem3 = ImageData(x=70, y=60, width=90, height=40) - + changes = AlignmentManager.align_left([elem1, elem2, elem3]) - + # All should align to x=30 (leftmost) assert elem1.position == (30, 20) assert elem2.position == (30, 40) assert elem3.position == (30, 60) - + # Check undo information assert len(changes) == 3 assert changes[0] == (elem1, (50, 20)) @@ -72,16 +72,16 @@ class TestAlignmentManager: def test_align_right_multiple_elements(self): """Test align_right with multiple elements""" elem1 = ImageData(x=50, y=20, width=100, height=50) # right edge at 150 - elem2 = ImageData(x=30, y=40, width=80, height=60) # right edge at 110 - elem3 = ImageData(x=70, y=60, width=90, height=40) # right edge at 160 - + elem2 = ImageData(x=30, y=40, width=80, height=60) # right edge at 110 + elem3 = ImageData(x=70, y=60, width=90, height=40) # right edge at 160 + changes = AlignmentManager.align_right([elem1, elem2, elem3]) - + # All right edges should align to x=160 (rightmost) assert elem1.position[0] == 60 # 160 - 100 assert elem2.position[0] == 80 # 160 - 80 assert elem3.position[0] == 70 # 160 - 90 - + # Y positions should not change assert elem1.position[1] == 20 assert elem2.position[1] == 40 @@ -92,14 +92,14 @@ class TestAlignmentManager: elem1 = ImageData(x=50, y=30, width=100, height=50) elem2 = ImageData(x=30, y=20, width=80, height=60) elem3 = ImageData(x=70, y=40, width=90, height=40) - + changes = AlignmentManager.align_top([elem1, elem2, elem3]) - + # All should align to y=20 (topmost) assert elem1.position[1] == 20 assert elem2.position[1] == 20 assert elem3.position[1] == 20 - + # X positions should not change assert elem1.position[0] == 50 assert elem2.position[0] == 30 @@ -108,16 +108,16 @@ class TestAlignmentManager: def test_align_bottom_multiple_elements(self): """Test align_bottom with multiple elements""" elem1 = ImageData(x=50, y=30, width=100, height=50) # bottom at 80 - elem2 = ImageData(x=30, y=20, width=80, height=60) # bottom at 80 - elem3 = ImageData(x=70, y=40, width=90, height=50) # bottom at 90 - + elem2 = ImageData(x=30, y=20, width=80, height=60) # bottom at 80 + elem3 = ImageData(x=70, y=40, width=90, height=50) # bottom at 90 + changes = AlignmentManager.align_bottom([elem1, elem2, elem3]) - + # All bottom edges should align to y=90 (bottommost) assert elem1.position[1] == 40 # 90 - 50 assert elem2.position[1] == 30 # 90 - 60 assert elem3.position[1] == 40 # 90 - 50 - + # X positions should not change assert elem1.position[0] == 50 assert elem2.position[0] == 30 @@ -125,18 +125,18 @@ class TestAlignmentManager: def test_align_horizontal_center_multiple_elements(self): """Test align_horizontal_center with multiple elements""" - elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 100 - elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70 - elem3 = ImageData(x=70, y=60, width=60, height=40) # center at 100 - + elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 100 + elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70 + elem3 = ImageData(x=70, y=60, width=60, height=40) # center at 100 + changes = AlignmentManager.align_horizontal_center([elem1, elem2, elem3]) - + # Average center = (100 + 70 + 100) / 3 = 90 # All elements should center at x=90 - assert abs(elem1.position[0] + elem1.size[0]/2 - 90) < 0.01 - assert abs(elem2.position[0] + elem2.size[0]/2 - 90) < 0.01 - assert abs(elem3.position[0] + elem3.size[0]/2 - 90) < 0.01 - + assert abs(elem1.position[0] + elem1.size[0] / 2 - 90) < 0.01 + assert abs(elem2.position[0] + elem2.size[0] / 2 - 90) < 0.01 + assert abs(elem3.position[0] + elem3.size[0] / 2 - 90) < 0.01 + # Y positions should not change assert elem1.position[1] == 20 assert elem2.position[1] == 40 @@ -144,18 +144,18 @@ class TestAlignmentManager: def test_align_vertical_center_multiple_elements(self): """Test align_vertical_center with multiple elements""" - elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 45 - elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70 - elem3 = ImageData(x=70, y=30, width=60, height=40) # center at 50 - + elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 45 + elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70 + elem3 = ImageData(x=70, y=30, width=60, height=40) # center at 50 + changes = AlignmentManager.align_vertical_center([elem1, elem2, elem3]) - + # Average center = (45 + 70 + 50) / 3 = 55 # All elements should center at y=55 - assert abs(elem1.position[1] + elem1.size[1]/2 - 55) < 0.01 - assert abs(elem2.position[1] + elem2.size[1]/2 - 55) < 0.01 - assert abs(elem3.position[1] + elem3.size[1]/2 - 55) < 0.01 - + assert abs(elem1.position[1] + elem1.size[1] / 2 - 55) < 0.01 + assert abs(elem2.position[1] + elem2.size[1] / 2 - 55) < 0.01 + assert abs(elem3.position[1] + elem3.size[1] / 2 - 55) < 0.01 + # X positions should not change assert elem1.position[0] == 50 assert elem2.position[0] == 30 @@ -178,14 +178,14 @@ class TestAlignmentManager: elem1 = ImageData(x=50, y=20, width=100, height=50) elem2 = ImageData(x=30, y=40, width=80, height=60) elem3 = ImageData(x=70, y=60, width=90, height=40) - + changes = AlignmentManager.make_same_size([elem1, elem2, elem3]) - + # All should match elem1's size assert elem1.size == (100, 50) assert elem2.size == (100, 50) assert elem3.size == (100, 50) - + # Check undo information (only elem2 and elem3 change) assert len(changes) == 2 assert changes[0][0] == elem2 @@ -198,14 +198,14 @@ class TestAlignmentManager: elem1 = ImageData(x=50, y=20, width=100, height=50) elem2 = ImageData(x=30, y=40, width=80, height=60) elem3 = ImageData(x=70, y=60, width=90, height=40) - + changes = AlignmentManager.make_same_width([elem1, elem2, elem3]) - + # All widths should match elem1 assert elem1.size[0] == 100 assert elem2.size[0] == 100 assert elem3.size[0] == 100 - + # Heights should not change assert elem1.size[1] == 50 assert elem2.size[1] == 60 @@ -216,14 +216,14 @@ class TestAlignmentManager: elem1 = ImageData(x=50, y=20, width=100, height=50) elem2 = ImageData(x=30, y=40, width=80, height=60) elem3 = ImageData(x=70, y=60, width=90, height=40) - + changes = AlignmentManager.make_same_height([elem1, elem2, elem3]) - + # All heights should match elem1 assert elem1.size[1] == 50 assert elem2.size[1] == 50 assert elem3.size[1] == 50 - + # Widths should not change assert elem1.size[0] == 100 assert elem2.size[0] == 80 @@ -233,7 +233,7 @@ class TestAlignmentManager: """Test distribute_horizontally with less than 3 elements""" elem1 = ImageData(x=50, y=20, width=100, height=50) elem2 = ImageData(x=30, y=40, width=80, height=60) - + changes = AlignmentManager.distribute_horizontally([elem1, elem2]) assert changes == [] @@ -242,15 +242,15 @@ class TestAlignmentManager: elem1 = ImageData(x=0, y=20, width=100, height=50) elem2 = ImageData(x=50, y=40, width=80, height=60) elem3 = ImageData(x=200, y=60, width=90, height=40) - + changes = AlignmentManager.distribute_horizontally([elem1, elem2, elem3]) - + # Elements should be distributed evenly by their left edges # min_x = 0, max_x = 200, span = 200 # spacing = 200 / (3-1) = 100 positions = [elem.position[0] for elem in [elem1, elem2, elem3]] sorted_positions = sorted(positions) - + assert sorted_positions[0] == 0 assert sorted_positions[1] == 100 assert sorted_positions[2] == 200 @@ -260,15 +260,15 @@ class TestAlignmentManager: elem1 = ImageData(x=20, y=0, width=100, height=50) elem2 = ImageData(x=40, y=50, width=80, height=60) elem3 = ImageData(x=60, y=300, width=90, height=40) - + changes = AlignmentManager.distribute_vertically([elem1, elem2, elem3]) - + # Elements should be distributed evenly by their top edges # min_y = 0, max_y = 300, span = 300 # spacing = 300 / (3-1) = 150 positions = [elem.position[1] for elem in [elem1, elem2, elem3]] sorted_positions = sorted(positions) - + assert sorted_positions[0] == 0 assert sorted_positions[1] == 150 assert sorted_positions[2] == 300 @@ -277,7 +277,7 @@ class TestAlignmentManager: """Test space_horizontally with less than 3 elements""" elem1 = ImageData(x=50, y=20, width=100, height=50) elem2 = ImageData(x=200, y=40, width=80, height=60) - + changes = AlignmentManager.space_horizontally([elem1, elem2]) assert changes == [] @@ -286,17 +286,17 @@ class TestAlignmentManager: elem1 = ImageData(x=0, y=20, width=100, height=50) elem2 = ImageData(x=150, y=40, width=50, height=60) elem3 = ImageData(x=250, y=60, width=100, height=40) - + changes = AlignmentManager.space_horizontally([elem1, elem2, elem3]) - + # Total width = 100 + 50 + 100 = 250 # Span = 0 to 350 (250 + 100 from elem3) # Available space = 350 - 0 - 250 = 100 # Spacing = 100 / (3-1) = 50 - + # After sorting by x: elem1 at 0, elem2 after 100+50=150, elem3 after 150+50+50=250 sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[0]) - + assert sorted_elements[0].position[0] == 0 assert sorted_elements[1].position[0] == 150 # 0 + 100 + 50 assert sorted_elements[2].position[0] == 250 # 150 + 50 + 50 @@ -306,17 +306,17 @@ class TestAlignmentManager: elem1 = ImageData(x=20, y=0, width=100, height=50) elem2 = ImageData(x=40, y=100, width=80, height=30) elem3 = ImageData(x=60, y=200, width=90, height=50) - + changes = AlignmentManager.space_vertically([elem1, elem2, elem3]) - + # Total height = 50 + 30 + 50 = 130 # Span = 0 to 250 (200 + 50 from elem3) # Available space = 250 - 0 - 130 = 120 # Spacing = 120 / (3-1) = 60 - + # After sorting by y: elem1 at 0, elem2 after 50+60=110, elem3 after 110+30+60=200 sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[1]) - + assert sorted_elements[0].position[1] == 0 assert sorted_elements[1].position[1] == 110 # 0 + 50 + 60 assert sorted_elements[2].position[1] == 200 # 110 + 30 + 60 @@ -326,10 +326,10 @@ class TestAlignmentManager: elem1 = ImageData(x=50, y=20, width=100, height=50) elem2 = PlaceholderData(placeholder_type="image", x=30, y=40, width=80, height=60) elem3 = TextBoxData(text_content="Test", x=70, y=60, width=90, height=40) - + # Test align_left changes = AlignmentManager.align_left([elem1, elem2, elem3]) - + assert elem1.position[0] == 30 assert elem2.position[0] == 30 assert elem3.position[0] == 30 @@ -339,23 +339,23 @@ class TestAlignmentManager: elem1 = ImageData(x=50, y=20, width=100, height=50) elem2 = ImageData(x=30, y=40, width=80, height=60) elem3 = ImageData(x=70, y=60, width=90, height=40) - + # Test position changes changes = AlignmentManager.align_left([elem1, elem2, elem3]) - + for change in changes: assert len(change) == 2 # (element, old_position) assert isinstance(change[0], ImageData) assert isinstance(change[1], tuple) assert len(change[1]) == 2 # (x, y) - + # Test size changes elem1 = ImageData(x=50, y=20, width=100, height=50) elem2 = ImageData(x=30, y=40, width=80, height=60) elem3 = ImageData(x=70, y=60, width=90, height=40) - + changes = AlignmentManager.make_same_size([elem1, elem2, elem3]) - + for change in changes: assert len(change) == 3 # (element, old_position, old_size) assert isinstance(change[0], ImageData) @@ -368,15 +368,15 @@ class TestAlignmentManager: """Test that alignment operations only change intended properties""" elem1 = ImageData(x=50, y=20, width=100, height=50, rotation=45, z_index=5) elem2 = ImageData(x=30, y=40, width=80, height=60, rotation=90, z_index=3) - + AlignmentManager.align_left([elem1, elem2]) - + # Rotation and z_index should not change assert elem1.rotation == 45 assert elem1.z_index == 5 assert elem2.rotation == 90 assert elem2.z_index == 3 - + # Heights should not change assert elem1.size[1] == 50 assert elem2.size[1] == 60 @@ -387,10 +387,10 @@ class TestAlignmentManager: elem3 = ImageData(x=200, y=60, width=90, height=40) elem1 = ImageData(x=0, y=20, width=100, height=50) elem2 = ImageData(x=100, y=40, width=80, height=60) - + # Pass in random order changes = AlignmentManager.distribute_horizontally([elem3, elem1, elem2]) - + # Should still distribute correctly positions = sorted([elem1.position[0], elem2.position[0], elem3.position[0]]) assert positions[0] == 0 @@ -402,21 +402,21 @@ class TestAlignmentManager: elem1 = ImageData(x=0, y=0, width=50, height=50) elem2 = ImageData(x=100, y=0, width=100, height=50) elem3 = ImageData(x=250, y=0, width=75, height=50) - + changes = AlignmentManager.space_horizontally([elem1, elem2, elem3]) - + # Calculate expected spacing # Total width = 50 + 100 + 75 = 225 # rightmost edge = 250 + 75 = 325 # Available space = 325 - 0 - 225 = 100 # Spacing = 100 / 2 = 50 - + sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[0]) - + # Verify spacing between elements is equal gap1 = sorted_elements[1].position[0] - (sorted_elements[0].position[0] + sorted_elements[0].size[0]) gap2 = sorted_elements[2].position[0] - (sorted_elements[1].position[0] + sorted_elements[1].size[0]) - + assert abs(gap1 - 50) < 0.01 assert abs(gap2 - 50) < 0.01 @@ -452,7 +452,7 @@ class TestAlignmentManager: assert len(changes) == 1 assert changes[0][0] == elem assert changes[0][1] == (100, 80) # old position - assert changes[0][2] == (20, 15) # old size + assert changes[0][2] == (20, 15) # old size def test_maximize_pattern_two_elements_horizontal(self): """Test maximize_pattern with two elements side by side""" @@ -469,11 +469,11 @@ class TestAlignmentManager: # Elements should not overlap (min_gap = 2.0) gap_x = max( elem2.position[0] - (elem1.position[0] + elem1.size[0]), - elem1.position[0] - (elem2.position[0] + elem2.size[0]) + elem1.position[0] - (elem2.position[0] + elem2.size[0]), ) gap_y = max( elem2.position[1] - (elem1.position[1] + elem1.size[1]), - elem1.position[1] - (elem2.position[1] + elem2.size[1]) + elem1.position[1] - (elem2.position[1] + elem2.size[1]), ) # Either horizontal or vertical gap should be >= min_gap @@ -510,11 +510,11 @@ class TestAlignmentManager: # Calculate gaps between rectangles gap_x = max( elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]), - elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]) + elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]), ) gap_y = max( elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]), - elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]) + elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]), ) # At least one gap should be >= min_gap @@ -564,11 +564,7 @@ class TestAlignmentManager: elem4 = ImageData(x=140, y=90, width=10, height=10) page_size = (160, 110) - changes = AlignmentManager.maximize_pattern( - [elem1, elem2, elem3, elem4], - page_size, - min_gap=2.0 - ) + changes = AlignmentManager.maximize_pattern([elem1, elem2, elem3, elem4], page_size, min_gap=2.0) # All elements should grow for elem in [elem1, elem2, elem3, elem4]: @@ -584,11 +580,11 @@ class TestAlignmentManager: gap_x = max( elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]), - elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]) + elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]), ) gap_y = max( elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]), - elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]) + elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]), ) assert gap_x >= 2.0 or gap_y >= 2.0 diff --git a/tests/test_alignment_ops_mixin.py b/tests/test_alignment_ops_mixin.py index 86543d1..d9fb28c 100755 --- a/tests/test_alignment_ops_mixin.py +++ b/tests/test_alignment_ops_mixin.py @@ -81,7 +81,7 @@ class TestGetSelectedElementsList: class TestAlignLeft: """Test align_left method""" - @patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager") def test_align_left_success(self, mock_manager, qtbot): """Test aligning elements to the left""" window = TestAlignmentWindow() @@ -93,10 +93,7 @@ class TestAlignLeft: window.gl_widget.selected_elements = {element1, element2} # Mock AlignmentManager to return changes - mock_manager.align_left.return_value = [ - (element1, (100, 0)), - (element2, (200, 100)) - ] + mock_manager.align_left.return_value = [(element1, (100, 0)), (element2, (200, 100))] window.align_left() @@ -106,7 +103,7 @@ class TestAlignLeft: assert "aligned" in window._status_message.lower() assert "left" in window._status_message.lower() - @patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager") def test_align_left_no_changes(self, mock_manager, qtbot): """Test align left when no changes needed""" window = TestAlignmentWindow() @@ -143,7 +140,7 @@ class TestAlignLeft: class TestAlignRight: """Test align_right method""" - @patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager") def test_align_right_success(self, mock_manager, qtbot): """Test aligning elements to the right""" window = TestAlignmentWindow() @@ -154,10 +151,7 @@ class TestAlignRight: window.gl_widget.selected_elements = {element1, element2} - mock_manager.align_right.return_value = [ - (element1, (100, 0)), - (element2, (200, 100)) - ] + mock_manager.align_right.return_value = [(element1, (100, 0)), (element2, (200, 100))] window.align_right() @@ -169,7 +163,7 @@ class TestAlignRight: class TestAlignTop: """Test align_top method""" - @patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager") def test_align_top_success(self, mock_manager, qtbot): """Test aligning elements to the top""" window = TestAlignmentWindow() @@ -180,10 +174,7 @@ class TestAlignTop: window.gl_widget.selected_elements = {element1, element2} - mock_manager.align_top.return_value = [ - (element1, (0, 50)), - (element2, (100, 100)) - ] + mock_manager.align_top.return_value = [(element1, (0, 50)), (element2, (100, 100))] window.align_top() @@ -195,7 +186,7 @@ class TestAlignTop: class TestAlignBottom: """Test align_bottom method""" - @patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager") def test_align_bottom_success(self, mock_manager, qtbot): """Test aligning elements to the bottom""" window = TestAlignmentWindow() @@ -206,10 +197,7 @@ class TestAlignBottom: window.gl_widget.selected_elements = {element1, element2} - mock_manager.align_bottom.return_value = [ - (element1, (0, 50)), - (element2, (100, 100)) - ] + mock_manager.align_bottom.return_value = [(element1, (0, 50)), (element2, (100, 100))] window.align_bottom() @@ -221,7 +209,7 @@ class TestAlignBottom: class TestAlignHorizontalCenter: """Test align_horizontal_center method""" - @patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager") def test_align_horizontal_center_success(self, mock_manager, qtbot): """Test aligning elements to horizontal center""" window = TestAlignmentWindow() @@ -232,10 +220,7 @@ class TestAlignHorizontalCenter: window.gl_widget.selected_elements = {element1, element2} - mock_manager.align_horizontal_center.return_value = [ - (element1, (0, 0)), - (element2, (200, 100)) - ] + mock_manager.align_horizontal_center.return_value = [(element1, (0, 0)), (element2, (200, 100))] window.align_horizontal_center() @@ -247,7 +232,7 @@ class TestAlignHorizontalCenter: class TestAlignVerticalCenter: """Test align_vertical_center method""" - @patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager") def test_align_vertical_center_success(self, mock_manager, qtbot): """Test aligning elements to vertical center""" window = TestAlignmentWindow() @@ -258,10 +243,7 @@ class TestAlignVerticalCenter: window.gl_widget.selected_elements = {element1, element2} - mock_manager.align_vertical_center.return_value = [ - (element1, (0, 0)), - (element2, (100, 200)) - ] + mock_manager.align_vertical_center.return_value = [(element1, (0, 0)), (element2, (100, 200))] window.align_vertical_center() @@ -273,7 +255,7 @@ class TestAlignVerticalCenter: class TestAlignmentCommandPattern: """Test alignment operations with command pattern for undo/redo""" - @patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager") def test_alignment_creates_command(self, mock_manager, qtbot): """Test that alignment creates a command for undo""" window = TestAlignmentWindow() @@ -284,10 +266,7 @@ class TestAlignmentCommandPattern: window.gl_widget.selected_elements = {element1, element2} - mock_manager.align_left.return_value = [ - (element1, (100, 0)), - (element2, (200, 100)) - ] + mock_manager.align_left.return_value = [(element1, (100, 0)), (element2, (200, 100))] # Should have no commands initially assert not window.project.history.can_undo() @@ -297,7 +276,7 @@ class TestAlignmentCommandPattern: # Should have created a command assert window.project.history.can_undo() - @patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager") def test_alignment_undo_redo(self, mock_manager, qtbot): """Test that alignment can be undone and redone""" window = TestAlignmentWindow() @@ -309,10 +288,7 @@ class TestAlignmentCommandPattern: window.gl_widget.selected_elements = {element1, element2} # Mock alignment to return changes (command will handle actual moves) - mock_manager.align_top.return_value = [ - (element1, (100, 0)), - (element2, (200, 100)) - ] + mock_manager.align_top.return_value = [(element1, (100, 0)), (element2, (200, 100))] # Execute alignment - command created window.align_top() diff --git a/tests/test_asset_drop_mixin.py b/tests/test_asset_drop_mixin.py index 83e3f66..ab7b4d8 100755 --- a/tests/test_asset_drop_mixin.py +++ b/tests/test_asset_drop_mixin.py @@ -28,8 +28,8 @@ class TestAssetDropWidget(AssetDropMixin, AssetPathMixin, PageNavigationMixin, V def _get_project_folder(self): """Override to access project via window mock""" main_window = self.window() - if hasattr(main_window, 'project') and main_window.project: - return getattr(main_window.project, 'folder_path', None) + if hasattr(main_window, "project") and main_window.project: + return getattr(main_window.project, "folder_path", None) return None @@ -43,7 +43,7 @@ class TestAssetDropInitialization: # Should accept drops (set in GLWidget.__init__) # This is a property of the widget, not the mixin - assert hasattr(widget, 'acceptDrops') + assert hasattr(widget, "acceptDrops") class TestDragEnterEvent: @@ -141,7 +141,7 @@ class TestDragMoveEvent: class TestDropEvent: """Test dropEvent method""" - @patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand') + @patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand") def test_drop_creates_image_element(self, mock_cmd_class, qtbot): """Test dropping image file creates ImageData element""" widget = TestAssetDropWidget() @@ -239,7 +239,7 @@ class TestDropEvent: # Create a real test image file test_image = tmp_path / "test_image.jpg" - test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100) # Minimal JPEG header + test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) # Minimal JPEG header # Setup project with page containing placeholder mock_window = Mock() @@ -248,6 +248,7 @@ class TestDropEvent: page = Page(layout=PageLayout(width=210, height=297), page_number=1) from pyPhotoAlbum.models import PlaceholderData + placeholder = PlaceholderData(x=100, y=100, width=200, height=150) page.layout.elements.append(placeholder) @@ -280,7 +281,7 @@ class TestDropEvent: # Image path should now be in assets folder (imported) assert page.layout.elements[0].image_path.startswith("assets/") - @patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand') + @patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand") def test_drop_multiple_files(self, mock_cmd_class, qtbot): """Test dropping first image from multiple files""" widget = TestAssetDropWidget() @@ -311,11 +312,13 @@ class TestDropEvent: # Create drop event with multiple files (only first is used) mime_data = QMimeData() - mime_data.setUrls([ - QUrl.fromLocalFile("/path/to/image1.jpg"), - QUrl.fromLocalFile("/path/to/image2.png"), - QUrl.fromLocalFile("/path/to/image3.jpg") - ]) + mime_data.setUrls( + [ + QUrl.fromLocalFile("/path/to/image1.jpg"), + QUrl.fromLocalFile("/path/to/image2.png"), + QUrl.fromLocalFile("/path/to/image3.jpg"), + ] + ) event = Mock() event.mimeData = Mock(return_value=mime_data) @@ -364,7 +367,7 @@ class TestDropEvent: # Create a real test image file test_image = tmp_path / "new_image.jpg" - test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100) + test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) # Setup project with page containing existing ImageData mock_window = Mock() @@ -372,10 +375,7 @@ class TestDropEvent: mock_window.project.working_dpi = 96 page = Page(layout=PageLayout(width=210, height=297), page_number=1) - existing_image = ImageData( - image_path="assets/old_image.jpg", - x=100, y=100, width=200, height=150 - ) + existing_image = ImageData(image_path="assets/old_image.jpg", x=100, y=100, width=200, height=150) page.layout.elements.append(existing_image) mock_window.project.pages = [page] @@ -407,24 +407,19 @@ class TestDropEvent: widget.update = Mock() test_image = tmp_path / "test.jpg" - test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100) + test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) mock_window = Mock() mock_window.project = Project(name="Test") page = Page(layout=PageLayout(width=210, height=297), page_number=1) - existing_image = ImageData( - image_path="assets/old.jpg", - x=100, y=100, width=200, height=150 - ) + existing_image = ImageData(image_path="assets/old.jpg", x=100, y=100, width=200, height=150) page.layout.elements.append(existing_image) mock_window.project.pages = [page] # Mock asset manager to raise exception mock_window.project.asset_manager = Mock() - mock_window.project.asset_manager.import_asset = Mock( - side_effect=Exception("Import failed") - ) + mock_window.project.asset_manager.import_asset = Mock(side_effect=Exception("Import failed")) widget.window = Mock(return_value=mock_window) widget._get_element_at = Mock(return_value=existing_image) @@ -454,7 +449,7 @@ class TestDropEvent: # Create a corrupted/invalid image file corrupted_image = tmp_path / "corrupted.jpg" - corrupted_image.write_bytes(b'not a valid image') + corrupted_image.write_bytes(b"not a valid image") mock_window = Mock() mock_window.project = Project(name="Test") @@ -488,7 +483,8 @@ class TestDropEvent: # Should use default dimensions (200, 150) from _calculate_image_dimensions # Check that AddElementCommand was called with an ImageData from pyPhotoAlbum.commands import AddElementCommand - with patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand') as mock_cmd: + + with patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand") as mock_cmd: # Re-run to check the call widget.dropEvent(event) assert mock_cmd.called @@ -527,10 +523,7 @@ class TestExtractImagePathEdgeCases: widget.update = Mock() mime_data = QMimeData() - mime_data.setUrls([ - QUrl.fromLocalFile("/path/to/document.pdf"), - QUrl.fromLocalFile("/path/to/file.txt") - ]) + mime_data.setUrls([QUrl.fromLocalFile("/path/to/document.pdf"), QUrl.fromLocalFile("/path/to/file.txt")]) event = Mock() event.mimeData = Mock(return_value=mime_data) @@ -576,7 +569,7 @@ class TestPlaceholderReplacementEdgeCases: widget.update = Mock() test_image = tmp_path / "test.jpg" - test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100) + test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) # Setup project WITHOUT pages mock_window = Mock() @@ -585,6 +578,7 @@ class TestPlaceholderReplacementEdgeCases: mock_window.project.pages = [] # Empty pages list from pyPhotoAlbum.models import PlaceholderData + placeholder = PlaceholderData(x=100, y=100, width=200, height=150) mock_window.project.asset_manager = Mock() diff --git a/tests/test_asset_path_mixin.py b/tests/test_asset_path_mixin.py new file mode 100644 index 0000000..64def84 --- /dev/null +++ b/tests/test_asset_path_mixin.py @@ -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 diff --git a/tests/test_async_loading_mixin.py b/tests/test_async_loading_mixin.py new file mode 100644 index 0000000..e40f307 --- /dev/null +++ b/tests/test_async_loading_mixin.py @@ -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 diff --git a/tests/test_autosave_manager.py b/tests/test_autosave_manager.py new file mode 100644 index 0000000..a108a61 --- /dev/null +++ b/tests/test_autosave_manager.py @@ -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 diff --git a/tests/test_base_mixin.py b/tests/test_base_mixin.py index aea24dd..9db9655 100755 --- a/tests/test_base_mixin.py +++ b/tests/test_base_mixin.py @@ -249,7 +249,7 @@ class TestDialogMethods: qtbot.addWidget(window) mock_critical = Mock() - monkeypatch.setattr(QMessageBox, 'critical', mock_critical) + monkeypatch.setattr(QMessageBox, "critical", mock_critical) window.show_error("Error Title", "Error message") @@ -260,7 +260,7 @@ class TestDialogMethods: qtbot.addWidget(window) mock_warning = Mock() - monkeypatch.setattr(QMessageBox, 'warning', mock_warning) + monkeypatch.setattr(QMessageBox, "warning", mock_warning) window.show_warning("Warning Title", "Warning message") @@ -271,7 +271,7 @@ class TestDialogMethods: qtbot.addWidget(window) mock_info = Mock() - monkeypatch.setattr(QMessageBox, 'information', mock_info) + monkeypatch.setattr(QMessageBox, "information", mock_info) window.show_info("Info Title", "Info message") @@ -309,7 +309,7 @@ class TestRequirePage: window._gl_widget = Mock() mock_warning = Mock() - monkeypatch.setattr(QMessageBox, 'warning', mock_warning) + monkeypatch.setattr(QMessageBox, "warning", mock_warning) result = window.require_page(show_warning=True) @@ -366,7 +366,7 @@ class TestRequireSelection: window._gl_widget = gl_widget mock_info = Mock() - monkeypatch.setattr(QMessageBox, 'information', mock_info) + monkeypatch.setattr(QMessageBox, "information", mock_info) result = window.require_selection(min_count=1, show_warning=True) @@ -386,7 +386,7 @@ class TestRequireSelection: window._gl_widget = gl_widget mock_info = Mock() - monkeypatch.setattr(QMessageBox, 'information', mock_info) + monkeypatch.setattr(QMessageBox, "information", mock_info) result = window.require_selection(min_count=3, show_warning=True) diff --git a/tests/test_commands.py b/tests/test_commands.py index 9fbd140..70ec2eb 100755 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -16,7 +16,7 @@ from pyPhotoAlbum.commands import ( ChangeZOrderCommand, StateChangeCommand, CommandHistory, - _normalize_asset_path + _normalize_asset_path, ) from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData from pyPhotoAlbum.page_layout import PageLayout @@ -107,9 +107,9 @@ class TestAddElementCommand: data = cmd.serialize() - assert data['type'] == 'add_element' - assert 'element' in data - assert data['executed'] is True + assert data["type"] == "add_element" + assert "element" in data + assert data["executed"] is True def test_add_element_with_asset_manager(self): """Test add element with asset manager reference""" @@ -164,8 +164,8 @@ class TestDeleteElementCommand: cmd = DeleteElementCommand(layout, element) data = cmd.serialize() - assert data['type'] == 'delete_element' - assert 'element' in data + assert data["type"] == "delete_element" + assert "element" in data class TestMoveElementCommand: @@ -198,9 +198,9 @@ class TestMoveElementCommand: cmd = MoveElementCommand(element, old_position=(100, 100), new_position=(200, 200)) data = cmd.serialize() - assert data['type'] == 'move_element' - assert data['old_position'] == (100, 100) - assert data['new_position'] == (200, 200) + assert data["type"] == "move_element" + assert data["old_position"] == (100, 100) + assert data["new_position"] == (200, 200) class TestResizeElementCommand: @@ -211,11 +211,7 @@ class TestResizeElementCommand: element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = ResizeElementCommand( - element, - old_position=(100, 100), - old_size=(200, 150), - new_position=(100, 100), - new_size=(300, 225) + element, old_position=(100, 100), old_size=(200, 150), new_position=(100, 100), new_size=(300, 225) ) cmd.execute() @@ -226,11 +222,7 @@ class TestResizeElementCommand: element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = ResizeElementCommand( - element, - old_position=(100, 100), - old_size=(200, 150), - new_position=(100, 100), - new_size=(300, 225) + element, old_position=(100, 100), old_size=(200, 150), new_position=(100, 100), new_size=(300, 225) ) cmd.execute() @@ -243,11 +235,7 @@ class TestResizeElementCommand: element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = ResizeElementCommand( - element, - old_position=(100, 100), - old_size=(200, 150), - new_position=(90, 90), - new_size=(220, 165) + element, old_position=(100, 100), old_size=(200, 150), new_position=(90, 90), new_size=(220, 165) ) cmd.execute() @@ -298,9 +286,9 @@ class TestRotateElementCommand: cmd = RotateElementCommand(element, old_rotation=0, new_rotation=45) data = cmd.serialize() - assert data['type'] == 'rotate_element' - assert data['old_rotation'] == 0 - assert data['new_rotation'] == 45 + assert data["type"] == "rotate_element" + assert data["old_rotation"] == 0 + assert data["new_rotation"] == 45 class TestAdjustImageCropCommand: @@ -310,32 +298,25 @@ class TestAdjustImageCropCommand: """Test adjusting image crop""" element = ImageData( image_path="/test.jpg", - x=100, y=100, - width=200, height=150, - crop_info={'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0} + x=100, + y=100, + width=200, + height=150, + crop_info={"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0}, ) - new_crop = {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8} - cmd = AdjustImageCropCommand( - element, - old_crop_info=element.crop_info.copy(), - new_crop_info=new_crop - ) + new_crop = {"x": 0.1, "y": 0.1, "width": 0.8, "height": 0.8} + cmd = AdjustImageCropCommand(element, old_crop_info=element.crop_info.copy(), new_crop_info=new_crop) cmd.execute() assert element.crop_info == new_crop def test_adjust_crop_undo(self): """Test undoing crop adjustment""" - old_crop = {'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0} - element = ImageData( - image_path="/test.jpg", - x=100, y=100, - width=200, height=150, - crop_info=old_crop.copy() - ) + old_crop = {"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0} + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150, crop_info=old_crop.copy()) - new_crop = {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8} + new_crop = {"x": 0.1, "y": 0.1, "width": 0.8, "height": 0.8} cmd = AdjustImageCropCommand(element, old_crop_info=old_crop, new_crop_info=new_crop) cmd.execute() @@ -399,10 +380,7 @@ class TestResizeElementsCommand: element2.size = (300, 300) # Command expects list of (element, old_position, old_size) tuples - changes = [ - (element1, (100, 100), (100, 100)), - (element2, (200, 200), (150, 150)) - ] + changes = [(element1, (100, 100), (100, 100)), (element2, (200, 200), (150, 150))] cmd = ResizeElementsCommand(changes) cmd.execute() @@ -421,10 +399,7 @@ class TestResizeElementsCommand: element2.size = (300, 300) # Command expects list of (element, old_position, old_size) tuples - changes = [ - (element1, (100, 100), (100, 100)), - (element2, (200, 200), (150, 150)) - ] + changes = [(element1, (100, 100), (100, 100)), (element2, (200, 200), (150, 150))] cmd = ResizeElementsCommand(changes) cmd.execute() @@ -476,81 +451,68 @@ class TestStateChangeCommand: def test_state_change_undo(self): """Test undoing state change""" - element = TextBoxData( - text_content="Old Text", - x=100, y=100, - width=200, height=100 - ) + element = TextBoxData(text_content="Old Text", x=100, y=100, width=200, height=100) # Define restore function def restore_state(state): - element.text_content = state['text_content'] + element.text_content = state["text_content"] - old_state = {'text_content': 'Old Text'} - new_state = {'text_content': 'New Text'} + old_state = {"text_content": "Old Text"} + new_state = {"text_content": "New Text"} # Apply new state first - element.text_content = 'New Text' + element.text_content = "New Text" cmd = StateChangeCommand( - description="Change text", - restore_func=restore_state, - before_state=old_state, - after_state=new_state + description="Change text", restore_func=restore_state, before_state=old_state, after_state=new_state ) # Undo should restore old state cmd.undo() - assert element.text_content == 'Old Text' + assert element.text_content == "Old Text" def test_state_change_redo(self): """Test redoing state change""" - element = TextBoxData( - text_content="Old Text", - x=100, y=100, - width=200, height=100 - ) + element = TextBoxData(text_content="Old Text", x=100, y=100, width=200, height=100) # Define restore function def restore_state(state): - element.text_content = state['text_content'] + element.text_content = state["text_content"] - old_state = {'text_content': 'Old Text'} - new_state = {'text_content': 'New Text'} + old_state = {"text_content": "Old Text"} + new_state = {"text_content": "New Text"} # Apply new state first - element.text_content = 'New Text' + element.text_content = "New Text" cmd = StateChangeCommand( - description="Change text", - restore_func=restore_state, - before_state=old_state, - after_state=new_state + description="Change text", restore_func=restore_state, before_state=old_state, after_state=new_state ) # Undo then redo cmd.undo() - assert element.text_content == 'Old Text' + assert element.text_content == "Old Text" cmd.redo() - assert element.text_content == 'New Text' + assert element.text_content == "New Text" def test_state_change_serialization(self): """Test serializing state change command""" + def restore_func(state): pass cmd = StateChangeCommand( description="Test operation", restore_func=restore_func, - before_state={'test': 'before'}, - after_state={'test': 'after'} + before_state={"test": "before"}, + after_state={"test": "after"}, ) data = cmd.serialize() - assert data['type'] == 'state_change' - assert data['description'] == 'Test operation' + assert data["type"] == "state_change" + assert data["description"] == "Test operation" class TestCommandHistory: @@ -656,7 +618,7 @@ class TestCommandHistory: layout = PageLayout(width=210, height=297) for i in range(5): - element = ImageData(image_path=f"/test{i}.jpg", x=i*10, y=i*10, width=100, height=100) + element = ImageData(image_path=f"/test{i}.jpg", x=i * 10, y=i * 10, width=100, height=100) history.execute(AddElementCommand(layout, element)) # Should only have 3 commands in history (max_history) @@ -678,8 +640,8 @@ class TestCommandHistory: # Serialize data = history.serialize() - assert len(data['undo_stack']) == 1 - assert data['undo_stack'][0]['type'] == 'add_element' + assert len(data["undo_stack"]) == 1 + assert data["undo_stack"][0]["type"] == "add_element" # Create mock project for deserialization mock_project = Mock() @@ -734,7 +696,7 @@ class TestCommandHistory: # Manually build serialized history data data = { - 'undo_stack': [ + "undo_stack": [ cmd1.serialize(), cmd2.serialize(), cmd3.serialize(), @@ -745,8 +707,8 @@ class TestCommandHistory: cmd8.serialize(), cmd9.serialize(), ], - 'redo_stack': [], - 'max_history': 100 + "redo_stack": [], + "max_history": 100, } # Create mock project @@ -758,15 +720,15 @@ class TestCommandHistory: new_history.deserialize(data, mock_project) assert len(new_history.undo_stack) == 9 - assert new_history.undo_stack[0].__class__.__name__ == 'AddElementCommand' - assert new_history.undo_stack[1].__class__.__name__ == 'DeleteElementCommand' - assert new_history.undo_stack[2].__class__.__name__ == 'MoveElementCommand' - assert new_history.undo_stack[3].__class__.__name__ == 'ResizeElementCommand' - assert new_history.undo_stack[4].__class__.__name__ == 'RotateElementCommand' - assert new_history.undo_stack[5].__class__.__name__ == 'AdjustImageCropCommand' - assert new_history.undo_stack[6].__class__.__name__ == 'AlignElementsCommand' - assert new_history.undo_stack[7].__class__.__name__ == 'ResizeElementsCommand' - assert new_history.undo_stack[8].__class__.__name__ == 'ChangeZOrderCommand' + assert new_history.undo_stack[0].__class__.__name__ == "AddElementCommand" + assert new_history.undo_stack[1].__class__.__name__ == "DeleteElementCommand" + assert new_history.undo_stack[2].__class__.__name__ == "MoveElementCommand" + assert new_history.undo_stack[3].__class__.__name__ == "ResizeElementCommand" + assert new_history.undo_stack[4].__class__.__name__ == "RotateElementCommand" + assert new_history.undo_stack[5].__class__.__name__ == "AdjustImageCropCommand" + assert new_history.undo_stack[6].__class__.__name__ == "AlignElementsCommand" + assert new_history.undo_stack[7].__class__.__name__ == "ResizeElementsCommand" + assert new_history.undo_stack[8].__class__.__name__ == "ChangeZOrderCommand" def test_history_deserialize_unknown_command_type(self): """Test deserializing unknown command type returns None and continues""" @@ -774,12 +736,12 @@ class TestCommandHistory: mock_project = Mock() data = { - 'undo_stack': [ - {'type': 'unknown_command', 'data': 'test'}, - {'type': 'add_element', 'element': ImageData().serialize(), 'executed': True} + "undo_stack": [ + {"type": "unknown_command", "data": "test"}, + {"type": "add_element", "element": ImageData().serialize(), "executed": True}, ], - 'redo_stack': [], - 'max_history': 100 + "redo_stack": [], + "max_history": 100, } # Should not raise exception, just skip unknown command @@ -787,7 +749,7 @@ class TestCommandHistory: # Should only have the valid command assert len(history.undo_stack) == 1 - assert history.undo_stack[0].__class__.__name__ == 'AddElementCommand' + assert history.undo_stack[0].__class__.__name__ == "AddElementCommand" def test_history_deserialize_malformed_command(self): """Test deserializing malformed command handles exception gracefully""" @@ -795,13 +757,17 @@ class TestCommandHistory: mock_project = Mock() data = { - 'undo_stack': [ - {'type': 'add_element'}, # Missing required 'element' field - {'type': 'move_element', 'element': ImageData().serialize(), - 'old_position': (0, 0), 'new_position': (10, 10)} + "undo_stack": [ + {"type": "add_element"}, # Missing required 'element' field + { + "type": "move_element", + "element": ImageData().serialize(), + "old_position": (0, 0), + "new_position": (10, 10), + }, ], - 'redo_stack': [], - 'max_history': 100 + "redo_stack": [], + "max_history": 100, } # Should not raise exception, just skip malformed command @@ -809,7 +775,7 @@ class TestCommandHistory: # Should only have the valid command assert len(history.undo_stack) == 1 - assert history.undo_stack[0].__class__.__name__ == 'MoveElementCommand' + assert history.undo_stack[0].__class__.__name__ == "MoveElementCommand" def test_history_serialize_deserialize_with_redo_stack(self): """Test serializing and deserializing with items in redo stack""" @@ -826,8 +792,8 @@ class TestCommandHistory: # Serialize data = history.serialize() - assert len(data['undo_stack']) == 1 - assert len(data['redo_stack']) == 1 + assert len(data["undo_stack"]) == 1 + assert len(data["redo_stack"]) == 1 # Deserialize mock_project = Mock() diff --git a/tests/test_distribution_ops_mixin.py b/tests/test_distribution_ops_mixin.py index 83d2498..7b5dd8e 100755 --- a/tests/test_distribution_ops_mixin.py +++ b/tests/test_distribution_ops_mixin.py @@ -56,7 +56,7 @@ class TestGetSelectedElementsList: class TestDistributeHorizontally: """Test distribute_horizontally method""" - @patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager") def test_distribute_horizontally_success(self, mock_manager, qtbot): window = TestDistributionWindow() qtbot.addWidget(window) @@ -70,7 +70,7 @@ class TestDistributeHorizontally: mock_manager.distribute_horizontally.return_value = [ (element1, (0, 0)), (element2, (150, 0)), - (element3, (500, 0)) + (element3, (500, 0)), ] window.distribute_horizontally() @@ -98,7 +98,7 @@ class TestDistributeHorizontally: class TestDistributeVertically: """Test distribute_vertically method""" - @patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager") def test_distribute_vertically_success(self, mock_manager, qtbot): window = TestDistributionWindow() qtbot.addWidget(window) @@ -112,7 +112,7 @@ class TestDistributeVertically: mock_manager.distribute_vertically.return_value = [ (element1, (0, 0)), (element2, (0, 150)), - (element3, (0, 500)) + (element3, (0, 500)), ] window.distribute_vertically() @@ -125,7 +125,7 @@ class TestDistributeVertically: class TestSpaceHorizontally: """Test space_horizontally method""" - @patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager") def test_space_horizontally_success(self, mock_manager, qtbot): window = TestDistributionWindow() qtbot.addWidget(window) @@ -136,11 +136,7 @@ class TestSpaceHorizontally: window.gl_widget.selected_elements = {element1, element2, element3} - mock_manager.space_horizontally.return_value = [ - (element1, (0, 0)), - (element2, (100, 0)), - (element3, (200, 0)) - ] + mock_manager.space_horizontally.return_value = [(element1, (0, 0)), (element2, (100, 0)), (element3, (200, 0))] window.space_horizontally() @@ -152,7 +148,7 @@ class TestSpaceHorizontally: class TestSpaceVertically: """Test space_vertically method""" - @patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager") def test_space_vertically_success(self, mock_manager, qtbot): window = TestDistributionWindow() qtbot.addWidget(window) @@ -163,11 +159,7 @@ class TestSpaceVertically: window.gl_widget.selected_elements = {element1, element2, element3} - mock_manager.space_vertically.return_value = [ - (element1, (0, 0)), - (element2, (0, 100)), - (element3, (0, 200)) - ] + mock_manager.space_vertically.return_value = [(element1, (0, 0)), (element2, (0, 100)), (element3, (0, 200))] window.space_vertically() @@ -178,7 +170,7 @@ class TestSpaceVertically: class TestDistributionCommandPattern: """Test distribution operations with command pattern""" - @patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager") def test_distribution_creates_command(self, mock_manager, qtbot): window = TestDistributionWindow() qtbot.addWidget(window) @@ -192,7 +184,7 @@ class TestDistributionCommandPattern: mock_manager.distribute_horizontally.return_value = [ (element1, (0, 0)), (element2, (100, 0)), - (element3, (200, 0)) + (element3, (200, 0)), ] assert not window.project.history.can_undo() diff --git a/tests/test_edit_ops_mixin.py b/tests/test_edit_ops_mixin.py index cd5afcd..d0df1b4 100755 --- a/tests/test_edit_ops_mixin.py +++ b/tests/test_edit_ops_mixin.py @@ -31,7 +31,7 @@ class TestEditWindow(EditOperationsMixin, QMainWindow): return len(self.gl_widget.selected_elements) >= min_count def get_current_page(self): - if hasattr(self, '_current_page'): + if hasattr(self, "_current_page"): return self._current_page return None diff --git a/tests/test_element_manipulation_mixin.py b/tests/test_element_manipulation_mixin.py index af9989c..859a0fd 100755 --- a/tests/test_element_manipulation_mixin.py +++ b/tests/test_element_manipulation_mixin.py @@ -15,6 +15,7 @@ from pyPhotoAlbum.page_layout import PageLayout # Create test widget combining necessary mixins class TestManipulationWidget(ElementManipulationMixin, ElementSelectionMixin, QOpenGLWidget): """Test widget combining manipulation and selection mixins""" + def __init__(self): super().__init__() self._page_renderers = [] @@ -36,11 +37,7 @@ class TestElementManipulationInitialization: assert widget.rotation_mode is False assert widget.rotation_start_angle is None assert widget.rotation_snap_angle == 15 - assert widget.snap_state == { - 'is_snapped': False, - 'last_position': None, - 'last_size': None - } + assert widget.snap_state == {"is_snapped": False, "last_position": None, "last_size": None} def test_rotation_mode_is_mutable(self, qtbot): """Test that rotation mode can be toggled""" @@ -72,7 +69,7 @@ class TestResizeElementNoSnap: elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150) widget.selected_element = elem - widget.resize_handle = 'se' + widget.resize_handle = "se" widget.resize_start_pos = (100, 100) widget.resize_start_size = (200, 150) @@ -89,7 +86,7 @@ class TestResizeElementNoSnap: elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150) widget.selected_element = elem - widget.resize_handle = 'nw' + widget.resize_handle = "nw" widget.resize_start_pos = (100, 100) widget.resize_start_size = (200, 150) @@ -106,7 +103,7 @@ class TestResizeElementNoSnap: elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150) widget.selected_element = elem - widget.resize_handle = 'ne' + widget.resize_handle = "ne" widget.resize_start_pos = (100, 100) widget.resize_start_size = (200, 150) @@ -123,7 +120,7 @@ class TestResizeElementNoSnap: elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150) widget.selected_element = elem - widget.resize_handle = 'sw' + widget.resize_handle = "sw" widget.resize_start_pos = (100, 100) widget.resize_start_size = (200, 150) @@ -140,7 +137,7 @@ class TestResizeElementNoSnap: elem = ImageData(image_path="test.jpg", x=100, y=100, width=50, height=50) widget.selected_element = elem - widget.resize_handle = 'se' + widget.resize_handle = "se" widget.resize_start_pos = (100, 100) widget.resize_start_size = (50, 50) @@ -157,7 +154,7 @@ class TestResizeElementNoSnap: elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150) widget.selected_element = elem - widget.resize_handle = 'se' + widget.resize_handle = "se" # Don't set resize_start_pos or resize_start_size original_pos = elem.position @@ -185,7 +182,7 @@ class TestResizeElementWithSnap: elem._parent_page = page widget.selected_element = elem - widget.resize_handle = 'se' + widget.resize_handle = "se" widget.resize_start_pos = (100, 100) widget.resize_start_size = (200, 150) @@ -208,7 +205,7 @@ class TestResizeElementWithSnap: params = call_args[0][0] assert params.dx == 50 assert params.dy == 30 - assert params.resize_handle == 'se' + assert params.resize_handle == "se" # Verify element was updated assert elem.size == (250, 180) @@ -220,7 +217,7 @@ class TestResizeElementWithSnap: elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150) widget.selected_element = elem - widget.resize_handle = 'se' + widget.resize_handle = "se" widget.resize_start_pos = (100, 100) widget.resize_start_size = (200, 150) @@ -241,7 +238,7 @@ class TestResizeElementWithSnap: elem._parent_page = page widget.selected_element = elem - widget.resize_handle = 'se' + widget.resize_handle = "se" widget.resize_start_pos = (100, 100) widget.resize_start_size = (50, 50) @@ -344,20 +341,20 @@ class TestManipulationStateManagement: widget = TestManipulationWidget() qtbot.addWidget(widget) - assert 'is_snapped' in widget.snap_state - assert 'last_position' in widget.snap_state - assert 'last_size' in widget.snap_state + assert "is_snapped" in widget.snap_state + assert "last_position" in widget.snap_state + assert "last_size" in widget.snap_state def test_resize_state_can_be_set(self, qtbot): """Test resize state variables can be set""" widget = TestManipulationWidget() qtbot.addWidget(widget) - widget.resize_handle = 'nw' + widget.resize_handle = "nw" widget.resize_start_pos = (10, 20) widget.resize_start_size = (100, 200) - assert widget.resize_handle == 'nw' + assert widget.resize_handle == "nw" assert widget.resize_start_pos == (10, 20) assert widget.resize_start_size == (100, 200) diff --git a/tests/test_element_maximizer.py b/tests/test_element_maximizer.py index 72c8191..e8497dc 100644 --- a/tests/test_element_maximizer.py +++ b/tests/test_element_maximizer.py @@ -258,6 +258,7 @@ class TestElementMaximizer: def test_maximize_empty_elements(self): """Test maximize with empty element list.""" from pyPhotoAlbum.alignment import AlignmentManager + result = AlignmentManager.maximize_pattern([], (200.0, 200.0)) assert result == [] diff --git a/tests/test_element_ops_mixin.py b/tests/test_element_ops_mixin.py index dcae6b4..34340ff 100755 --- a/tests/test_element_ops_mixin.py +++ b/tests/test_element_ops_mixin.py @@ -43,11 +43,11 @@ class TestElementWindow(ElementOperationsMixin, AssetPathMixin, QMainWindow): def require_page(self): """Track require_page calls""" self._require_page_called = True - return self._current_page is not None if hasattr(self, '_current_page') else False + return self._current_page is not None if hasattr(self, "_current_page") else False def get_current_page(self): """Return mock current page""" - if hasattr(self, '_current_page'): + if hasattr(self, "_current_page"): return self._current_page return None @@ -71,8 +71,8 @@ class TestElementWindow(ElementOperationsMixin, AssetPathMixin, QMainWindow): class TestAddImage: """Test add_image method""" - @patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName') - @patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions') + @patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName") + @patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions") def test_add_image_success(self, mock_get_dims, mock_file_dialog, qtbot): """Test successfully adding an image""" window = TestElementWindow() @@ -106,7 +106,7 @@ class TestAddImage: assert window._update_view_called assert "added image" in window._status_message.lower() - @patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName') + @patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName") def test_add_image_cancelled(self, mock_file_dialog, qtbot): """Test cancelling image selection""" window = TestElementWindow() @@ -139,8 +139,8 @@ class TestAddImage: assert window._require_page_called assert not window._update_view_called - @patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName') - @patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions') + @patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName") + @patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions") def test_add_image_scales_large_image(self, mock_get_dims, mock_file_dialog, qtbot): """Test that large images are scaled down""" window = TestElementWindow() @@ -164,8 +164,8 @@ class TestAddImage: # Image should be added (scaled down by get_image_dimensions) assert window._update_view_called - @patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName') - @patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions') + @patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName") + @patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions") def test_add_image_fallback_dimensions(self, mock_get_dims, mock_file_dialog, qtbot): """Test fallback dimensions when get_image_dimensions returns None""" window = TestElementWindow() @@ -294,8 +294,8 @@ class TestAddPlaceholder: class TestElementOperationsIntegration: """Test integration between element operations""" - @patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName') - @patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions') + @patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName") + @patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions") def test_add_multiple_elements(self, mock_get_dims, mock_file_dialog, qtbot): """Test adding multiple different element types""" window = TestElementWindow() @@ -326,8 +326,8 @@ class TestElementOperationsIntegration: # Should have added all three elements assert window._update_view_called - @patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName') - @patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions') + @patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName") + @patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions") def test_add_image_with_undo(self, mock_get_dims, mock_file_dialog, qtbot): """Test that adding image can be undone""" window = TestElementWindow() diff --git a/tests/test_element_selection_mixin.py b/tests/test_element_selection_mixin.py index e80a978..464ef03 100755 --- a/tests/test_element_selection_mixin.py +++ b/tests/test_element_selection_mixin.py @@ -23,19 +23,19 @@ def mock_page_renderer(): # Mock coordinate conversion methods def page_to_screen(x, y): - return (renderer.screen_x + x * renderer.zoom, - renderer.screen_y + y * renderer.zoom) + return (renderer.screen_x + x * renderer.zoom, renderer.screen_y + y * renderer.zoom) def screen_to_page(x, y): - return ((x - renderer.screen_x) / renderer.zoom, - (y - renderer.screen_y) / renderer.zoom) + return ((x - renderer.screen_x) / renderer.zoom, (y - renderer.screen_y) / renderer.zoom) def is_point_in_page(x, y): # Simple bounds check (assume 210mm x 297mm page at 96 DPI) page_width_px = 210 * 96 / 25.4 page_height_px = 297 * 96 / 25.4 - return (renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom and - renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom) + return ( + renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom + and renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom + ) renderer.page_to_screen = page_to_screen renderer.screen_to_page = screen_to_page @@ -47,6 +47,7 @@ def mock_page_renderer(): # Create a minimal test widget class class TestSelectionWidget(ElementSelectionMixin, QOpenGLWidget): """Test widget combining ElementSelectionMixin with QOpenGLWidget""" + def __init__(self): super().__init__() self._page_renderers = [] @@ -60,7 +61,7 @@ class TestElementSelectionInitialization: widget = TestSelectionWidget() qtbot.addWidget(widget) - assert hasattr(widget, 'selected_elements') + assert hasattr(widget, "selected_elements") assert isinstance(widget.selected_elements, set) assert len(widget.selected_elements) == 0 @@ -172,8 +173,8 @@ class TestGetElementAt: assert result is not None assert result == elem - assert hasattr(result, '_page_renderer') - assert hasattr(result, '_parent_page') + assert hasattr(result, "_page_renderer") + assert hasattr(result, "_parent_page") def test_get_element_at_finds_topmost_element(self, qtbot, mock_page_renderer): """Test _get_element_at returns topmost element when overlapping""" @@ -246,8 +247,8 @@ class TestGetElementAt: # Screen coords: (50 + 200, 50 + 175) = (250, 225) result = widget._get_element_at(250, 225) assert result == elem - assert hasattr(result, '_page_renderer') - assert hasattr(result, '_parent_page') + assert hasattr(result, "_page_renderer") + assert hasattr(result, "_parent_page") def test_get_element_at_rotated_element_outside(self, qtbot, mock_page_renderer): """Test _get_element_at correctly rejects clicks outside rotated element""" @@ -288,8 +289,8 @@ class TestGetElementAt: # Should be able to select the element even though it's off the page assert result is not None assert result == elem - assert hasattr(result, '_page_renderer') - assert hasattr(result, '_parent_page') + assert hasattr(result, "_page_renderer") + assert hasattr(result, "_parent_page") class TestGetResizeHandleAt: @@ -330,6 +331,7 @@ class TestGetResizeHandleAt: # Mock window with project from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.page_layout import PageLayout + mock_window = Mock() mock_window.project = Project(name="Test") page = Page(layout=PageLayout(width=210, height=297), page_number=1) @@ -351,6 +353,7 @@ class TestGetResizeHandleAt: # Mock window with project from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.page_layout import PageLayout + mock_window = Mock() mock_window.project = Project(name="Test") page = Page(layout=PageLayout(width=210, height=297), page_number=1) @@ -359,7 +362,7 @@ class TestGetResizeHandleAt: # Click on NW handle (screen: 50 + 100 = 150, 50 + 100 = 150) result = widget._get_resize_handle_at(150, 150) - assert result == 'nw' + assert result == "nw" def test_get_resize_handle_detects_all_corners(self, qtbot, mock_page_renderer): """Test _get_resize_handle_at detects all four corners""" @@ -373,6 +376,7 @@ class TestGetResizeHandleAt: # Mock window from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.page_layout import PageLayout + mock_window = Mock() mock_window.project = Project(name="Test") page = Page(layout=PageLayout(width=210, height=297), page_number=1) @@ -380,16 +384,16 @@ class TestGetResizeHandleAt: widget.window = Mock(return_value=mock_window) # NW corner (screen: 50 + 100 = 150, 50 + 100 = 150) - assert widget._get_resize_handle_at(150, 150) == 'nw' + assert widget._get_resize_handle_at(150, 150) == "nw" # NE corner (screen: 50 + 300 = 350, 50 + 100 = 150) - assert widget._get_resize_handle_at(350, 150) == 'ne' + assert widget._get_resize_handle_at(350, 150) == "ne" # SW corner (screen: 50 + 100 = 150, 50 + 250 = 300) - assert widget._get_resize_handle_at(150, 300) == 'sw' + assert widget._get_resize_handle_at(150, 300) == "sw" # SE corner (screen: 50 + 300 = 350, 50 + 250 = 300) - assert widget._get_resize_handle_at(350, 300) == 'se' + assert widget._get_resize_handle_at(350, 300) == "se" def test_get_resize_handle_returns_none_for_center(self, qtbot, mock_page_renderer): """Test _get_resize_handle_at returns None for element center""" @@ -403,6 +407,7 @@ class TestGetResizeHandleAt: # Mock window from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.page_layout import PageLayout + mock_window = Mock() mock_window.project = Project(name="Test") page = Page(layout=PageLayout(width=210, height=297), page_number=1) @@ -426,6 +431,7 @@ class TestGetResizeHandleAt: # Mock window from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.page_layout import PageLayout + mock_window = Mock() mock_window.project = Project(name="Test") page = Page(layout=PageLayout(width=210, height=297), page_number=1) @@ -436,7 +442,7 @@ class TestGetResizeHandleAt: # For rotated element, the handle positions are transformed result = widget._get_resize_handle_at(150, 150) # Should detect a handle (exact handle depends on rotation transform) - assert result is None or result in ['nw', 'ne', 'sw', 'se'] + assert result is None or result in ["nw", "ne", "sw", "se"] def test_get_resize_handle_rotated_90_degrees(self, qtbot, mock_page_renderer): """Test _get_resize_handle_at handles 90-degree rotated elements""" @@ -451,6 +457,7 @@ class TestGetResizeHandleAt: # Mock window from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.page_layout import PageLayout + mock_window = Mock() mock_window.project = Project(name="Test") page = Page(layout=PageLayout(width=210, height=297), page_number=1) @@ -460,7 +467,7 @@ class TestGetResizeHandleAt: # Test clicking at various positions - rotation code should handle them # Just verify the method runs without crashing result = widget._get_resize_handle_at(200, 200) - assert result is None or result in ['nw', 'ne', 'sw', 'se'] + assert result is None or result in ["nw", "ne", "sw", "se"] class TestMultiSelect: diff --git a/tests/test_embedded_templates.py b/tests/test_embedded_templates.py index 18b14a0..eb04c4b 100755 --- a/tests/test_embedded_templates.py +++ b/tests/test_embedded_templates.py @@ -15,21 +15,18 @@ def test_embed_template_in_project(): """Test embedding a template in a project""" # Create a project project = Project(name="Test Project") - + # Create a template manager with the project template_manager = TemplateManager(project=project) - + # Create a simple template template = Template(name="Test Template", description="A test template") - placeholder = PlaceholderData( - placeholder_type="image", - x=10, y=10, width=100, height=100 - ) + placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100) template.add_element(placeholder) - + # Embed the template template_manager.embed_template(template) - + # Verify it's embedded assert "Test Template" in project.embedded_templates assert project.embedded_templates["Test Template"]["name"] == "Test Template" @@ -40,22 +37,19 @@ def test_load_embedded_template(): """Test loading an embedded template""" # Create a project project = Project(name="Test Project") - + # Create a template manager with the project template_manager = TemplateManager(project=project) - + # Create and embed a template template = Template(name="Test Template", description="A test template") - placeholder = PlaceholderData( - placeholder_type="image", - x=10, y=10, width=100, height=100 - ) + placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100) template.add_element(placeholder) template_manager.embed_template(template) - + # Load the embedded template loaded_template = template_manager.load_template("Test Template") - + assert loaded_template.name == "Test Template" assert loaded_template.description == "A test template" assert len(loaded_template.elements) == 1 @@ -65,18 +59,18 @@ def test_list_embedded_templates(): """Test listing embedded templates alongside filesystem templates""" # Create a project project = Project(name="Test Project") - + # Create a template manager with the project template_manager = TemplateManager(project=project) - + # Embed some templates for i in range(3): template = Template(name=f"Embedded_{i}") template_manager.embed_template(template) - + # List all templates templates = template_manager.list_templates() - + # Check embedded templates are listed with prefix embedded_templates = [t for t in templates if t.startswith("[Embedded]")] assert len(embedded_templates) == 3 @@ -89,14 +83,14 @@ def test_embedded_template_priority(): """Test that embedded templates take priority over filesystem templates""" # Create a project project = Project(name="Test Project") - + # Create a template manager with the project template_manager = TemplateManager(project=project) - + # Embed a template with a common name embedded_template = Template(name="Common", description="Embedded version") template_manager.embed_template(embedded_template) - + # Load by name without prefix (should get embedded version) loaded = template_manager.load_template("Common") assert loaded.description == "Embedded version" @@ -106,22 +100,19 @@ def test_serialize_project_with_embedded_templates(): """Test serializing a project with embedded templates""" # Create a project project = Project(name="Test Project") - + # Create a template manager with the project template_manager = TemplateManager(project=project) - + # Create and embed a template template = Template(name="Test Template", description="A test template") - placeholder = PlaceholderData( - placeholder_type="image", - x=10, y=10, width=100, height=100 - ) + placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100) template.add_element(placeholder) template_manager.embed_template(template) - + # Serialize the project serialized = project.serialize() - + # Verify embedded templates are in serialization assert "embedded_templates" in serialized assert "Test Template" in serialized["embedded_templates"] @@ -133,26 +124,23 @@ def test_deserialize_project_with_embedded_templates(): # Create a project with embedded template project = Project(name="Test Project") template_manager = TemplateManager(project=project) - + template = Template(name="Test Template", description="A test template") - placeholder = PlaceholderData( - placeholder_type="image", - x=10, y=10, width=100, height=100 - ) + placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100) template.add_element(placeholder) template_manager.embed_template(template) - + # Serialize the project serialized = project.serialize() - + # Create a new project and deserialize new_project = Project(name="New Project") new_project.deserialize(serialized) - + # Verify embedded templates were restored assert "Test Template" in new_project.embedded_templates assert new_project.embedded_templates["Test Template"]["name"] == "Test Template" - + # Verify we can load the template from the new project new_template_manager = TemplateManager(project=new_project) loaded_template = new_template_manager.load_template("Test Template") @@ -166,21 +154,18 @@ def test_auto_embed_on_apply(): project = Project(name="Test Project") page = Page() project.add_page(page) - + # Create a template manager with the project template_manager = TemplateManager(project=project) - + # Create a template (not embedded yet) template = Template(name="Auto Embed Test", description="Should auto-embed") - placeholder = PlaceholderData( - placeholder_type="image", - x=10, y=10, width=100, height=100 - ) + placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100) template.add_element(placeholder) - + # Apply template with auto_embed=True (default) template_manager.apply_template_to_page(template, page) - + # Verify template was auto-embedded assert "Auto Embed Test" in project.embedded_templates @@ -189,21 +174,18 @@ def test_auto_embed_on_create_page(): """Test that templates are automatically embedded when creating pages""" # Create a project project = Project(name="Test Project") - + # Create a template manager with the project template_manager = TemplateManager(project=project) - + # Create a template (not embedded yet) template = Template(name="Auto Embed Page Test", description="Should auto-embed") - placeholder = PlaceholderData( - placeholder_type="image", - x=10, y=10, width=100, height=100 - ) + placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100) template.add_element(placeholder) - + # Create page from template with auto_embed=True (default) page = template_manager.create_page_from_template(template, page_number=1) - + # Verify template was auto-embedded assert "Auto Embed Page Test" in project.embedded_templates @@ -212,19 +194,19 @@ def test_delete_embedded_template(): """Test deleting an embedded template""" # Create a project project = Project(name="Test Project") - + # Create a template manager with the project template_manager = TemplateManager(project=project) - + # Embed a template template = Template(name="To Delete") template_manager.embed_template(template) - + assert "To Delete" in project.embedded_templates - + # Delete the embedded template template_manager.delete_template("[Embedded] To Delete") - + assert "To Delete" not in project.embedded_templates @@ -232,22 +214,19 @@ def test_embedded_template_with_text(): """Test embedding template with text elements""" # Create a project project = Project(name="Test Project") - + # Create a template manager with the project template_manager = TemplateManager(project=project) - + # Create a template with text template = Template(name="Text Template") - textbox = TextBoxData( - text_content="Sample Text", - x=10, y=10, width=200, height=50 - ) + textbox = TextBoxData(text_content="Sample Text", x=10, y=10, width=200, height=50) template.add_element(textbox) - + # Embed and reload template_manager.embed_template(template) loaded = template_manager.load_template("Text Template") - + assert len(loaded.elements) == 1 assert isinstance(loaded.elements[0], TextBoxData) assert loaded.elements[0].text_content == "Sample Text" @@ -258,46 +237,38 @@ def test_roundtrip_serialization(): # Create a project with pages and embedded template project = Project(name="Roundtrip Test") template_manager = TemplateManager(project=project) - + # Create a template template = Template(name="Roundtrip Template", page_size_mm=(200, 300)) - placeholder1 = PlaceholderData( - placeholder_type="image", - x=10, y=10, width=80, height=80 - ) - placeholder2 = PlaceholderData( - placeholder_type="image", - x=110, y=10, width=80, height=80 - ) + placeholder1 = PlaceholderData(placeholder_type="image", x=10, y=10, width=80, height=80) + placeholder2 = PlaceholderData(placeholder_type="image", x=110, y=10, width=80, height=80) template.add_element(placeholder1) template.add_element(placeholder2) - + # Create a page from this template page = template_manager.create_page_from_template(template, page_number=1) project.add_page(page) - + # Serialize serialized = project.serialize() - + # Create new project and deserialize new_project = Project(name="New Roundtrip") new_project.deserialize(serialized) - + # Verify embedded template assert "Roundtrip Template" in new_project.embedded_templates - + # Verify we can use the template new_template_manager = TemplateManager(project=new_project) loaded_template = new_template_manager.load_template("Roundtrip Template") - + assert loaded_template.name == "Roundtrip Template" assert loaded_template.page_size_mm == (200, 300) assert len(loaded_template.elements) == 2 - + # Create another page from the loaded template new_page = new_template_manager.create_page_from_template( - loaded_template, - page_number=2, - auto_embed=False # Don't embed again + loaded_template, page_number=2, auto_embed=False # Don't embed again ) assert len(new_page.layout.elements) == 2 diff --git a/tests/test_gl_widget_integration.py b/tests/test_gl_widget_integration.py index 2de157a..e4cf8d6 100755 --- a/tests/test_gl_widget_integration.py +++ b/tests/test_gl_widget_integration.py @@ -21,13 +21,13 @@ class TestGLWidgetInitialization: qtbot.addWidget(widget) # Verify mixin state is initialized - assert hasattr(widget, 'zoom_level') - assert hasattr(widget, 'pan_offset') - assert hasattr(widget, 'selected_elements') - assert hasattr(widget, 'drag_start_pos') - assert hasattr(widget, 'is_dragging') - assert hasattr(widget, 'is_panning') - assert hasattr(widget, 'rotation_mode') + assert hasattr(widget, "zoom_level") + assert hasattr(widget, "pan_offset") + assert hasattr(widget, "selected_elements") + assert hasattr(widget, "drag_start_pos") + assert hasattr(widget, "is_dragging") + assert hasattr(widget, "is_panning") + assert hasattr(widget, "rotation_mode") def test_gl_widget_accepts_drops(self, qtbot): """Test GLWidget is configured to accept drops""" @@ -105,7 +105,7 @@ class TestGLWidgetMixinIntegration: QPointF(75, 75), Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, - Qt.KeyboardModifier.NoModifier + Qt.KeyboardModifier.NoModifier, ) widget.mousePressEvent(event) @@ -125,7 +125,7 @@ class TestGLWidgetMixinIntegration: # Begin operation (should be tracked for undo) widget._begin_move(element) assert widget._interaction_state.element is not None - assert widget._interaction_state.interaction_type == 'move' + assert widget._interaction_state.interaction_type == "move" assert widget._interaction_state.position == (100, 100) # End operation @@ -152,11 +152,8 @@ class TestGLWidgetKeyEvents: # Create key press event for Escape from PyQt6.QtGui import QKeyEvent - event = QKeyEvent( - QKeyEvent.Type.KeyPress, - Qt.Key.Key_Escape, - Qt.KeyboardModifier.NoModifier - ) + + event = QKeyEvent(QKeyEvent.Type.KeyPress, Qt.Key.Key_Escape, Qt.KeyboardModifier.NoModifier) widget.keyPressEvent(event) @@ -186,11 +183,8 @@ class TestGLWidgetKeyEvents: # Create key press event for Tab from PyQt6.QtGui import QKeyEvent - event = QKeyEvent( - QKeyEvent.Type.KeyPress, - Qt.Key.Key_Tab, - Qt.KeyboardModifier.NoModifier - ) + + event = QKeyEvent(QKeyEvent.Type.KeyPress, Qt.Key.Key_Tab, Qt.KeyboardModifier.NoModifier) widget.keyPressEvent(event) @@ -220,11 +214,8 @@ class TestGLWidgetKeyEvents: # Create key press event for Delete from PyQt6.QtGui import QKeyEvent - event = QKeyEvent( - QKeyEvent.Type.KeyPress, - Qt.Key.Key_Delete, - Qt.KeyboardModifier.NoModifier - ) + + event = QKeyEvent(QKeyEvent.Type.KeyPress, Qt.Key.Key_Delete, Qt.KeyboardModifier.NoModifier) widget.keyPressEvent(event) @@ -257,7 +248,7 @@ class TestGLWidgetWithProject: # Verify we can access project through widget main_window = widget.window() - assert hasattr(main_window, 'project') + assert hasattr(main_window, "project") assert main_window.project.name == "Test Project" assert len(main_window.project.pages) == 1 assert len(main_window.project.pages[0].layout.elements) == 1 @@ -321,6 +312,7 @@ class TestGLWidgetOpenGL: # Should have NoPartialUpdate set from PyQt6.QtOpenGLWidgets import QOpenGLWidget + assert widget.updateBehavior() == QOpenGLWidget.UpdateBehavior.NoPartialUpdate diff --git a/tests/test_image_pan_mixin.py b/tests/test_image_pan_mixin.py index cc81232..0743195 100755 --- a/tests/test_image_pan_mixin.py +++ b/tests/test_image_pan_mixin.py @@ -14,6 +14,7 @@ from pyPhotoAlbum.models import ImageData, PlaceholderData # Create test widget combining necessary mixins class TestImagePanWidget(ImagePanMixin, ElementSelectionMixin, ViewportMixin, QOpenGLWidget): """Test widget combining image pan, selection, and viewport mixins""" + def __init__(self): super().__init__() self.drag_start_pos = None diff --git a/tests/test_interaction_command_builders.py b/tests/test_interaction_command_builders.py index 2b4dcc8..c7933ef 100644 --- a/tests/test_interaction_command_builders.py +++ b/tests/test_interaction_command_builders.py @@ -8,7 +8,7 @@ from pyPhotoAlbum.mixins.interaction_command_builders import ( MoveCommandBuilder, ResizeCommandBuilder, RotateCommandBuilder, - ImagePanCommandBuilder + ImagePanCommandBuilder, ) from pyPhotoAlbum.mixins.interaction_validators import InteractionChangeDetector @@ -22,7 +22,7 @@ class TestMoveCommandBuilder: element = Mock() element.position = (10.0, 10.0) - start_state = {'position': (0.0, 0.0)} + start_state = {"position": (0.0, 0.0)} assert builder.can_build(element, start_state) @@ -32,7 +32,7 @@ class TestMoveCommandBuilder: element = Mock() element.position = (0.05, 0.05) - start_state = {'position': (0.0, 0.0)} + start_state = {"position": (0.0, 0.0)} assert not builder.can_build(element, start_state) @@ -52,7 +52,7 @@ class TestMoveCommandBuilder: element = Mock() element.position = (10.0, 10.0) - start_state = {'position': (0.0, 0.0)} + start_state = {"position": (0.0, 0.0)} command = builder.build(element, start_state) @@ -65,7 +65,7 @@ class TestMoveCommandBuilder: element = Mock() element.position = (0.05, 0.05) - start_state = {'position': (0.0, 0.0)} + start_state = {"position": (0.0, 0.0)} command = builder.build(element, start_state) @@ -82,10 +82,7 @@ class TestResizeCommandBuilder: element.position = (0.0, 0.0) element.size = (200.0, 200.0) - start_state = { - 'position': (0.0, 0.0), - 'size': (100.0, 100.0) - } + start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)} assert builder.can_build(element, start_state) @@ -96,10 +93,7 @@ class TestResizeCommandBuilder: element.position = (10.0, 10.0) element.size = (100.0, 100.0) - start_state = { - 'position': (0.0, 0.0), - 'size': (100.0, 100.0) - } + start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)} assert builder.can_build(element, start_state) @@ -110,10 +104,7 @@ class TestResizeCommandBuilder: element.position = (10.0, 10.0) element.size = (200.0, 200.0) - start_state = { - 'position': (0.0, 0.0), - 'size': (100.0, 100.0) - } + start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)} assert builder.can_build(element, start_state) @@ -124,10 +115,7 @@ class TestResizeCommandBuilder: element.position = (0.0, 0.0) element.size = (100.0, 100.0) - start_state = { - 'position': (0.0, 0.0), - 'size': (100.0, 100.0) - } + start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)} assert not builder.can_build(element, start_state) @@ -138,10 +126,7 @@ class TestResizeCommandBuilder: element.position = (10.0, 10.0) element.size = (200.0, 200.0) - start_state = { - 'position': (0.0, 0.0), - 'size': (100.0, 100.0) - } + start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)} command = builder.build(element, start_state) @@ -158,7 +143,7 @@ class TestRotateCommandBuilder: element = Mock() element.rotation = 45.0 - start_state = {'rotation': 0.0} + start_state = {"rotation": 0.0} assert builder.can_build(element, start_state) @@ -168,7 +153,7 @@ class TestRotateCommandBuilder: element = Mock() element.rotation = 0.05 - start_state = {'rotation': 0.0} + start_state = {"rotation": 0.0} assert not builder.can_build(element, start_state) @@ -178,7 +163,7 @@ class TestRotateCommandBuilder: element = Mock() element.rotation = 45.0 - start_state = {'rotation': 0.0} + start_state = {"rotation": 0.0} command = builder.build(element, start_state) @@ -197,7 +182,7 @@ class TestImagePanCommandBuilder: element = Mock(spec=ImageData) element.crop_info = (0.1, 0.1, 0.9, 0.9) - start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)} + start_state = {"crop_info": (0.0, 0.0, 1.0, 1.0)} assert builder.can_build(element, start_state) @@ -207,7 +192,7 @@ class TestImagePanCommandBuilder: element = Mock() element.crop_info = (0.1, 0.1, 0.9, 0.9) - start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)} + start_state = {"crop_info": (0.0, 0.0, 1.0, 1.0)} assert not builder.can_build(element, start_state) @@ -219,7 +204,7 @@ class TestImagePanCommandBuilder: element = Mock(spec=ImageData) element.crop_info = (0.0001, 0.0001, 1.0, 1.0) - start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)} + start_state = {"crop_info": (0.0, 0.0, 1.0, 1.0)} assert not builder.can_build(element, start_state) @@ -231,7 +216,7 @@ class TestImagePanCommandBuilder: element = Mock(spec=ImageData) element.crop_info = (0.1, 0.1, 0.9, 0.9) - start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)} + start_state = {"crop_info": (0.0, 0.0, 1.0, 1.0)} command = builder.build(element, start_state) @@ -250,7 +235,7 @@ class TestCommandBuilderIntegration: element = Mock() element.position = (5.0, 5.0) - start_state = {'position': (0.0, 0.0)} + start_state = {"position": (0.0, 0.0)} # With high threshold, this should not build assert not builder.can_build(element, start_state) @@ -261,7 +246,7 @@ class TestCommandBuilderIntegration: element = Mock() element.position = (10.0, 10.0) - start_state = {'position': (0.0, 0.0)} + start_state = {"position": (0.0, 0.0)} builder.build(element, start_state) diff --git a/tests/test_interaction_command_factory.py b/tests/test_interaction_command_factory.py index d924c1e..60c7837 100644 --- a/tests/test_interaction_command_factory.py +++ b/tests/test_interaction_command_factory.py @@ -4,10 +4,7 @@ Unit tests for interaction command factory. import pytest from unittest.mock import Mock -from pyPhotoAlbum.mixins.interaction_command_factory import ( - InteractionCommandFactory, - InteractionState -) +from pyPhotoAlbum.mixins.interaction_command_factory import InteractionCommandFactory, InteractionState from pyPhotoAlbum.mixins.interaction_command_builders import CommandBuilder @@ -18,71 +15,49 @@ class TestInteractionState: """Test that InteractionState initializes correctly.""" element = Mock() state = InteractionState( - element=element, - interaction_type='move', - position=(0.0, 0.0), - size=(100.0, 100.0), - rotation=0.0 + element=element, interaction_type="move", position=(0.0, 0.0), size=(100.0, 100.0), rotation=0.0 ) assert state.element == element - assert state.interaction_type == 'move' + assert state.interaction_type == "move" assert state.position == (0.0, 0.0) assert state.size == (100.0, 100.0) assert state.rotation == 0.0 def test_to_dict(self): """Test that to_dict returns correct dictionary.""" - state = InteractionState( - position=(0.0, 0.0), - size=(100.0, 100.0) - ) + state = InteractionState(position=(0.0, 0.0), size=(100.0, 100.0)) result = state.to_dict() - assert result == { - 'position': (0.0, 0.0), - 'size': (100.0, 100.0) - } + assert result == {"position": (0.0, 0.0), "size": (100.0, 100.0)} def test_to_dict_excludes_none(self): """Test that to_dict excludes None values.""" - state = InteractionState( - position=(0.0, 0.0), - size=None - ) + state = InteractionState(position=(0.0, 0.0), size=None) result = state.to_dict() - assert 'position' in result - assert 'size' not in result + assert "position" in result + assert "size" not in result def test_is_valid_with_required_fields(self): """Test that is_valid returns True when required fields are present.""" element = Mock() - state = InteractionState( - element=element, - interaction_type='move' - ) + state = InteractionState(element=element, interaction_type="move") assert state.is_valid() def test_is_valid_without_element(self): """Test that is_valid returns False without element.""" - state = InteractionState( - element=None, - interaction_type='move' - ) + state = InteractionState(element=None, interaction_type="move") assert not state.is_valid() def test_is_valid_without_interaction_type(self): """Test that is_valid returns False without interaction_type.""" element = Mock() - state = InteractionState( - element=element, - interaction_type=None - ) + state = InteractionState(element=element, interaction_type=None) assert not state.is_valid() @@ -90,11 +65,7 @@ class TestInteractionState: """Test that clear resets all fields.""" element = Mock() state = InteractionState( - element=element, - interaction_type='move', - position=(0.0, 0.0), - size=(100.0, 100.0), - rotation=0.0 + element=element, interaction_type="move", position=(0.0, 0.0), size=(100.0, 100.0), rotation=0.0 ) state.clear() @@ -113,19 +84,19 @@ class TestInteractionCommandFactory: """Test that factory initializes with default builders.""" factory = InteractionCommandFactory() - assert factory.has_builder('move') - assert factory.has_builder('resize') - assert factory.has_builder('rotate') - assert factory.has_builder('image_pan') + assert factory.has_builder("move") + assert factory.has_builder("resize") + assert factory.has_builder("rotate") + assert factory.has_builder("image_pan") def test_register_builder(self): """Test registering a custom builder.""" factory = InteractionCommandFactory() custom_builder = Mock(spec=CommandBuilder) - factory.register_builder('custom', custom_builder) + factory.register_builder("custom", custom_builder) - assert factory.has_builder('custom') + assert factory.has_builder("custom") def test_get_supported_types(self): """Test getting list of supported types.""" @@ -133,10 +104,10 @@ class TestInteractionCommandFactory: types = factory.get_supported_types() - assert 'move' in types - assert 'resize' in types - assert 'rotate' in types - assert 'image_pan' in types + assert "move" in types + assert "resize" in types + assert "rotate" in types + assert "image_pan" in types def test_create_command_move(self): """Test creating a move command.""" @@ -144,9 +115,9 @@ class TestInteractionCommandFactory: element = Mock() element.position = (10.0, 10.0) - start_state = {'position': (0.0, 0.0)} + start_state = {"position": (0.0, 0.0)} - command = factory.create_command('move', element, start_state) + command = factory.create_command("move", element, start_state) assert command is not None @@ -157,12 +128,9 @@ class TestInteractionCommandFactory: element.position = (10.0, 10.0) element.size = (200.0, 200.0) - start_state = { - 'position': (0.0, 0.0), - 'size': (100.0, 100.0) - } + start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)} - command = factory.create_command('resize', element, start_state) + command = factory.create_command("resize", element, start_state) assert command is not None @@ -172,9 +140,9 @@ class TestInteractionCommandFactory: element = Mock() element.rotation = 45.0 - start_state = {'rotation': 0.0} + start_state = {"rotation": 0.0} - command = factory.create_command('rotate', element, start_state) + command = factory.create_command("rotate", element, start_state) assert command is not None @@ -183,7 +151,7 @@ class TestInteractionCommandFactory: factory = InteractionCommandFactory() element = Mock() - command = factory.create_command('unknown', element, {}) + command = factory.create_command("unknown", element, {}) assert command is None captured = capsys.readouterr() @@ -195,9 +163,9 @@ class TestInteractionCommandFactory: element = Mock() element.position = (0.05, 0.05) - start_state = {'position': (0.0, 0.0)} + start_state = {"position": (0.0, 0.0)} - command = factory.create_command('move', element, start_state) + command = factory.create_command("move", element, start_state) assert command is None @@ -211,12 +179,12 @@ class TestInteractionCommandFactory: custom_builder.can_build.return_value = True custom_builder.build.return_value = mock_command - factory.register_builder('custom', custom_builder) + factory.register_builder("custom", custom_builder) element = Mock() - start_state = {'position': (0.0, 0.0)} + start_state = {"position": (0.0, 0.0)} - command = factory.create_command('custom', element, start_state) + command = factory.create_command("custom", element, start_state) assert command == mock_command custom_builder.can_build.assert_called_once() @@ -232,17 +200,9 @@ class TestInteractionStateIntegration: element = Mock() element.position = (10.0, 10.0) - state = InteractionState( - element=element, - interaction_type='move', - position=(0.0, 0.0) - ) + state = InteractionState(element=element, interaction_type="move", position=(0.0, 0.0)) - command = factory.create_command( - state.interaction_type, - state.element, - state.to_dict() - ) + command = factory.create_command(state.interaction_type, state.element, state.to_dict()) assert command is not None @@ -254,7 +214,7 @@ class TestInteractionStateIntegration: # Begin interaction state = InteractionState() state.element = element - state.interaction_type = 'move' + state.interaction_type = "move" state.position = element.position assert state.is_valid() @@ -264,11 +224,7 @@ class TestInteractionStateIntegration: # Create command factory = InteractionCommandFactory() - command = factory.create_command( - state.interaction_type, - state.element, - state.to_dict() - ) + command = factory.create_command(state.interaction_type, state.element, state.to_dict()) assert command is not None diff --git a/tests/test_interaction_undo_mixin.py b/tests/test_interaction_undo_mixin.py index d6a4c8f..f20dc52 100755 --- a/tests/test_interaction_undo_mixin.py +++ b/tests/test_interaction_undo_mixin.py @@ -29,8 +29,8 @@ class TestUndoableInteractionInitialization: qtbot.addWidget(widget) # Should have initialized tracking state object - assert hasattr(widget, '_interaction_state') - assert hasattr(widget, '_command_factory') + assert hasattr(widget, "_interaction_state") + assert hasattr(widget, "_command_factory") # State should be clear initially assert widget._interaction_state.element is None @@ -53,7 +53,7 @@ class TestBeginMove: widget._begin_move(element) assert widget._interaction_state.element is element - assert widget._interaction_state.interaction_type == 'move' + assert widget._interaction_state.interaction_type == "move" assert widget._interaction_state.position == (100, 100) assert widget._interaction_state.size is None assert widget._interaction_state.rotation is None @@ -87,7 +87,7 @@ class TestBeginResize: widget._begin_resize(element) assert widget._interaction_state.element is element - assert widget._interaction_state.interaction_type == 'resize' + assert widget._interaction_state.interaction_type == "resize" assert widget._interaction_state.position == (100, 100) assert widget._interaction_state.size == (200, 150) assert widget._interaction_state.rotation is None @@ -107,7 +107,7 @@ class TestBeginRotate: widget._begin_rotate(element) assert widget._interaction_state.element is element - assert widget._interaction_state.interaction_type == 'rotate' + assert widget._interaction_state.interaction_type == "rotate" assert widget._interaction_state.position is None assert widget._interaction_state.size is None assert widget._interaction_state.rotation == 45.0 @@ -121,17 +121,12 @@ class TestBeginImagePan: widget = TestUndoableWidget() qtbot.addWidget(widget) - element = ImageData( - image_path="/test.jpg", - x=100, y=100, - width=200, height=150, - crop_info=(0.1, 0.2, 0.8, 0.7) - ) + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150, crop_info=(0.1, 0.2, 0.8, 0.7)) widget._begin_image_pan(element) assert widget._interaction_state.element is element - assert widget._interaction_state.interaction_type == 'image_pan' + assert widget._interaction_state.interaction_type == "image_pan" assert widget._interaction_state.crop_info == (0.1, 0.2, 0.8, 0.7) def test_begin_image_pan_ignores_non_image(self, qtbot): @@ -151,7 +146,7 @@ class TestBeginImagePan: class TestEndInteraction: """Test _end_interaction method""" - @patch('pyPhotoAlbum.commands.MoveElementCommand') + @patch("pyPhotoAlbum.commands.MoveElementCommand") def test_end_interaction_creates_move_command(self, mock_cmd_class, qtbot): """Test that ending move interaction creates MoveElementCommand""" widget = TestUndoableWidget() @@ -177,7 +172,7 @@ class TestEndInteraction: mock_cmd_class.assert_called_once_with(element, (100, 100), (150, 160)) assert mock_window.project.history.execute.called - @patch('pyPhotoAlbum.commands.ResizeElementCommand') + @patch("pyPhotoAlbum.commands.ResizeElementCommand") def test_end_interaction_creates_resize_command(self, mock_cmd_class, qtbot): """Test that ending resize interaction creates ResizeElementCommand""" widget = TestUndoableWidget() @@ -204,12 +199,12 @@ class TestEndInteraction: element, (100, 100), # old position (200, 150), # old size - (90, 90), # new position - (250, 200) # new size + (90, 90), # new position + (250, 200), # new size ) assert mock_window.project.history.execute.called - @patch('pyPhotoAlbum.commands.RotateElementCommand') + @patch("pyPhotoAlbum.commands.RotateElementCommand") def test_end_interaction_creates_rotate_command(self, mock_cmd_class, qtbot): """Test that ending rotate interaction creates RotateElementCommand""" widget = TestUndoableWidget() @@ -235,7 +230,7 @@ class TestEndInteraction: mock_cmd_class.assert_called_once_with(element, 0, 90) assert mock_window.project.history.execute.called - @patch('pyPhotoAlbum.commands.AdjustImageCropCommand') + @patch("pyPhotoAlbum.commands.AdjustImageCropCommand") def test_end_interaction_creates_crop_command(self, mock_cmd_class, qtbot): """Test that ending image pan interaction creates AdjustImageCropCommand""" widget = TestUndoableWidget() @@ -248,9 +243,11 @@ class TestEndInteraction: element = ImageData( image_path="/test.jpg", - x=100, y=100, - width=200, height=150, - crop_info=(0.0, 0.0, 1.0, 1.0) # Tuple format used in code + x=100, + y=100, + width=200, + height=150, + crop_info=(0.0, 0.0, 1.0, 1.0), # Tuple format used in code ) widget._begin_image_pan(element) @@ -391,12 +388,7 @@ class TestClearInteractionState: widget = TestUndoableWidget() qtbot.addWidget(widget) - element = ImageData( - image_path="/test.jpg", - x=100, y=100, - width=200, height=150, - crop_info=(0.0, 0.0, 1.0, 1.0) - ) + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150, crop_info=(0.0, 0.0, 1.0, 1.0)) widget._begin_image_pan(element) # After begin_image_pan, crop_info should be stored @@ -452,10 +444,10 @@ class TestInteractionEdgeCases: widget._begin_rotate(element) # Should have rotate state (last call wins) - assert widget._interaction_state.interaction_type == 'rotate' + assert widget._interaction_state.interaction_type == "rotate" assert widget._interaction_state.rotation == 0 - @patch('pyPhotoAlbum.commands.ResizeElementCommand') + @patch("pyPhotoAlbum.commands.ResizeElementCommand") def test_resize_with_only_size_change(self, mock_cmd_class, qtbot): """Test resize command when only size changes (position same)""" widget = TestUndoableWidget() @@ -479,7 +471,7 @@ class TestInteractionEdgeCases: assert mock_cmd_class.called assert mock_window.project.history.execute.called - @patch('pyPhotoAlbum.commands.ResizeElementCommand') + @patch("pyPhotoAlbum.commands.ResizeElementCommand") def test_resize_with_only_position_change(self, mock_cmd_class, qtbot): """Test resize command when only position changes (size same)""" widget = TestUndoableWidget() diff --git a/tests/test_interaction_undo_refactored.py b/tests/test_interaction_undo_refactored.py index c9496db..a39417a 100644 --- a/tests/test_interaction_undo_refactored.py +++ b/tests/test_interaction_undo_refactored.py @@ -31,8 +31,8 @@ class TestUndoableInteractionMixinRefactored: """Test that mixin initializes correctly.""" widget = MockWidget() - assert hasattr(widget, '_command_factory') - assert hasattr(widget, '_interaction_state') + assert hasattr(widget, "_command_factory") + assert hasattr(widget, "_interaction_state") def test_begin_move(self): """Test beginning a move interaction.""" @@ -43,7 +43,7 @@ class TestUndoableInteractionMixinRefactored: widget._begin_move(element) assert widget._interaction_state.element == element - assert widget._interaction_state.interaction_type == 'move' + assert widget._interaction_state.interaction_type == "move" assert widget._interaction_state.position == (0.0, 0.0) def test_begin_resize(self): @@ -56,7 +56,7 @@ class TestUndoableInteractionMixinRefactored: widget._begin_resize(element) assert widget._interaction_state.element == element - assert widget._interaction_state.interaction_type == 'resize' + assert widget._interaction_state.interaction_type == "resize" assert widget._interaction_state.position == (0.0, 0.0) assert widget._interaction_state.size == (100.0, 100.0) @@ -69,7 +69,7 @@ class TestUndoableInteractionMixinRefactored: widget._begin_rotate(element) assert widget._interaction_state.element == element - assert widget._interaction_state.interaction_type == 'rotate' + assert widget._interaction_state.interaction_type == "rotate" assert widget._interaction_state.rotation == 0.0 def test_begin_image_pan(self): @@ -83,7 +83,7 @@ class TestUndoableInteractionMixinRefactored: widget._begin_image_pan(element) assert widget._interaction_state.element == element - assert widget._interaction_state.interaction_type == 'image_pan' + assert widget._interaction_state.interaction_type == "image_pan" assert widget._interaction_state.crop_info == (0.0, 0.0, 1.0, 1.0) def test_begin_image_pan_non_image_element(self): @@ -211,7 +211,7 @@ class TestUndoableInteractionMixinRefactored: """Test that ending interaction without project is safe.""" widget = MockWidget() # Remove the project attribute entirely - delattr(widget._mock_window, 'project') + delattr(widget._mock_window, "project") element = Mock(spec=BaseLayoutElement) element.position = (0.0, 0.0) diff --git a/tests/test_interaction_validators.py b/tests/test_interaction_validators.py index 733bc7c..5c8fa85 100644 --- a/tests/test_interaction_validators.py +++ b/tests/test_interaction_validators.py @@ -3,10 +3,7 @@ Unit tests for interaction validators and change detection. """ import pytest -from pyPhotoAlbum.mixins.interaction_validators import ( - ChangeValidator, - InteractionChangeDetector -) +from pyPhotoAlbum.mixins.interaction_validators import ChangeValidator, InteractionChangeDetector class TestChangeValidator: @@ -104,10 +101,10 @@ class TestInteractionChangeDetector: change = detector.detect_position_change(old_pos, new_pos) assert change is not None - assert change['old_position'] == old_pos - assert change['new_position'] == new_pos - assert change['delta_x'] == 5.0 - assert change['delta_y'] == 3.0 + assert change["old_position"] == old_pos + assert change["new_position"] == new_pos + assert change["delta_x"] == 5.0 + assert change["delta_y"] == 3.0 def test_detect_position_change_insignificant(self): """Test that insignificant position changes return None.""" @@ -128,10 +125,10 @@ class TestInteractionChangeDetector: change = detector.detect_size_change(old_size, new_size) assert change is not None - assert change['old_size'] == old_size - assert change['new_size'] == new_size - assert change['delta_width'] == 50.0 - assert change['delta_height'] == 20.0 + assert change["old_size"] == old_size + assert change["new_size"] == new_size + assert change["delta_width"] == 50.0 + assert change["delta_height"] == 20.0 def test_detect_rotation_change_significant(self): """Test detecting significant rotation changes.""" @@ -142,9 +139,9 @@ class TestInteractionChangeDetector: change = detector.detect_rotation_change(old_rotation, new_rotation) assert change is not None - assert change['old_rotation'] == old_rotation - assert change['new_rotation'] == new_rotation - assert change['delta_angle'] == 45.0 + assert change["old_rotation"] == old_rotation + assert change["new_rotation"] == new_rotation + assert change["delta_angle"] == 45.0 def test_detect_crop_change_significant(self): """Test detecting significant crop changes.""" @@ -155,13 +152,13 @@ class TestInteractionChangeDetector: change = detector.detect_crop_change(old_crop, new_crop) assert change is not None - assert change['old_crop'] == old_crop - assert change['new_crop'] == new_crop + assert change["old_crop"] == old_crop + assert change["new_crop"] == new_crop # Use approximate comparison for floating point - assert abs(change['delta'][0] - 0.1) < 0.001 - assert abs(change['delta'][1] - 0.1) < 0.001 - assert abs(change['delta'][2] - (-0.1)) < 0.001 - assert abs(change['delta'][3] - (-0.1)) < 0.001 + assert abs(change["delta"][0] - 0.1) < 0.001 + assert abs(change["delta"][1] - 0.1) < 0.001 + assert abs(change["delta"][2] - (-0.1)) < 0.001 + assert abs(change["delta"][3] - (-0.1)) < 0.001 def test_custom_threshold(self): """Test using custom threshold values.""" diff --git a/tests/test_merge.py b/tests/test_merge.py index 0f4a2c0..99cdedf 100755 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -26,10 +26,7 @@ def create_base_project(): # Add a page with text page = Page(page_number=1) - text = TextBoxData( - text_content="Original Text", - x=10, y=10, width=100, height=50 - ) + text = TextBoxData(text_content="Original Text", x=10, y=10, width=100, height=50) page.layout.add_element(text) project.add_page(page) @@ -145,7 +142,7 @@ def test_different_project_concatenation(): print("\n3. Concatenating projects...") merged_data = concatenate_projects(data_a, data_b) - assert len(merged_data['pages']) == 2, "Should have 2 pages" + assert len(merged_data["pages"]) == 2, "Should have 2 pages" print(f" ✓ Concatenated project has {len(merged_data['pages'])} pages") print(f" ✓ Combined name: {merged_data['name']}") @@ -252,6 +249,7 @@ def run_all_tests(): except Exception as e: print(f"\n❌ Test '{name}' FAILED with exception: {e}") import traceback + traceback.print_exc() results.append((name, False)) @@ -293,20 +291,20 @@ def test_merge_helper_add_missing_pages(): data_b = project_b.serialize() # Make them same project - data_b['project_id'] = data_a['project_id'] + data_b["project_id"] = data_a["project_id"] merge_manager = MergeManager() merge_manager.detect_conflicts(data_a, data_b) # Test _add_missing_pages merged_data = data_a.copy() - merged_data['pages'] = list(data_a['pages']) - initial_page_count = len(merged_data['pages']) + merged_data["pages"] = list(data_a["pages"]) + initial_page_count = len(merged_data["pages"]) merge_manager._add_missing_pages(merged_data, data_b) # Should have added only page_b2 since page_b1 has same UUID as page_a1 - assert len(merged_data['pages']) == initial_page_count + 1 + assert len(merged_data["pages"]) == initial_page_count + 1 print(f" ✓ Added missing page: {len(merged_data['pages'])} total pages") print(f"\n{'=' * 60}") @@ -332,7 +330,7 @@ def test_merge_helper_is_element_in_conflict(): element_uuid="elem-456", our_version={}, their_version={}, - description="Test conflict" + description="Test conflict", ) merge_manager.conflicts.append(conflict) @@ -365,50 +363,24 @@ def test_merge_helper_merge_by_timestamp(): older = (now - timedelta(hours=1)).isoformat() newer = (now + timedelta(hours=1)).isoformat() - our_page = { - 'layout': { - 'elements': [ - { - 'uuid': 'elem-1', - 'text_content': 'Older version', - 'last_modified': older - } - ] - } - } + our_page = {"layout": {"elements": [{"uuid": "elem-1", "text_content": "Older version", "last_modified": older}]}} - our_elem = our_page['layout']['elements'][0] - their_elem = { - 'uuid': 'elem-1', - 'text_content': 'Newer version', - 'last_modified': newer - } + our_elem = our_page["layout"]["elements"][0] + their_elem = {"uuid": "elem-1", "text_content": "Newer version", "last_modified": newer} # Test: their version is newer, should replace - merge_manager._merge_by_timestamp(our_page, 'elem-1', their_elem, our_elem) + merge_manager._merge_by_timestamp(our_page, "elem-1", their_elem, our_elem) - assert our_page['layout']['elements'][0]['text_content'] == 'Newer version' + assert our_page["layout"]["elements"][0]["text_content"] == "Newer version" print(f" ✓ Correctly replaced with newer version") # Test: our version is newer, should not replace - our_page['layout']['elements'][0] = { - 'uuid': 'elem-2', - 'text_content': 'Our newer version', - 'last_modified': newer - } - their_elem_older = { - 'uuid': 'elem-2', - 'text_content': 'Their older version', - 'last_modified': older - } + our_page["layout"]["elements"][0] = {"uuid": "elem-2", "text_content": "Our newer version", "last_modified": newer} + their_elem_older = {"uuid": "elem-2", "text_content": "Their older version", "last_modified": older} - merge_manager._merge_by_timestamp( - our_page, 'elem-2', - their_elem_older, - our_page['layout']['elements'][0] - ) + merge_manager._merge_by_timestamp(our_page, "elem-2", their_elem_older, our_page["layout"]["elements"][0]) - assert our_page['layout']['elements'][0]['text_content'] == 'Our newer version' + assert our_page["layout"]["elements"][0]["text_content"] == "Our newer version" print(f" ✓ Correctly kept our newer version") print(f"\n{'=' * 60}") @@ -430,73 +402,49 @@ def test_merge_helper_merge_element(): # Setup: page with one element our_page = { - 'uuid': 'page-1', - 'layout': { - 'elements': [ - { - 'uuid': 'elem-existing', - 'text_content': 'Existing', - 'last_modified': now - } - ] - } + "uuid": "page-1", + "layout": {"elements": [{"uuid": "elem-existing", "text_content": "Existing", "last_modified": now}]}, } - our_elements = { - 'elem-existing': our_page['layout']['elements'][0] - } + our_elements = {"elem-existing": our_page["layout"]["elements"][0]} # Test 1: Adding new element - their_new_elem = { - 'uuid': 'elem-new', - 'text_content': 'New element', - 'last_modified': now - } + their_new_elem = {"uuid": "elem-new", "text_content": "New element", "last_modified": now} merge_manager._merge_element( - our_page=our_page, - page_uuid='page-1', - their_elem=their_new_elem, - our_elements=our_elements + our_page=our_page, page_uuid="page-1", their_elem=their_new_elem, our_elements=our_elements ) - assert len(our_page['layout']['elements']) == 2 - assert our_page['layout']['elements'][1]['uuid'] == 'elem-new' + assert len(our_page["layout"]["elements"]) == 2 + assert our_page["layout"]["elements"][1]["uuid"] == "elem-new" print(f" ✓ Correctly added new element") # Test 2: Element in conflict should be skipped from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType - conflict_elem = { - 'uuid': 'elem-conflict', - 'text_content': 'Conflict element', - 'last_modified': now - } + conflict_elem = {"uuid": "elem-conflict", "text_content": "Conflict element", "last_modified": now} conflict = ConflictInfo( conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH, - page_uuid='page-1', - element_uuid='elem-conflict', + page_uuid="page-1", + element_uuid="elem-conflict", our_version={}, their_version={}, - description="Test" + description="Test", ) merge_manager.conflicts.append(conflict) - our_elements['elem-conflict'] = {'uuid': 'elem-conflict', 'text_content': 'Ours'} - our_page['layout']['elements'].append(our_elements['elem-conflict']) + our_elements["elem-conflict"] = {"uuid": "elem-conflict", "text_content": "Ours"} + our_page["layout"]["elements"].append(our_elements["elem-conflict"]) - initial_count = len(our_page['layout']['elements']) + initial_count = len(our_page["layout"]["elements"]) merge_manager._merge_element( - our_page=our_page, - page_uuid='page-1', - their_elem=conflict_elem, - our_elements=our_elements + our_page=our_page, page_uuid="page-1", their_elem=conflict_elem, our_elements=our_elements ) # Should not change anything since it's in conflict - assert len(our_page['layout']['elements']) == initial_count + assert len(our_page["layout"]["elements"]) == initial_count print(f" ✓ Correctly skipped conflicting element") print(f"\n{'=' * 60}") diff --git a/tests/test_migration.py b/tests/test_migration.py index b771c6a..bc0c8c0 100755 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -49,29 +49,16 @@ def create_v2_project_json(): "rotation": 0, "z_index": 0, "text_content": "Hello v2.0", - "font_settings": { - "family": "Arial", - "size": 12, - "color": [0, 0, 0] - }, - "alignment": "left" + "font_settings": {"family": "Arial", "size": 12, "color": [0, 0, 0]}, + "alignment": "left", } ], - "snapping_system": { - "snap_threshold_mm": 5.0, - "grid_size_mm": 10.0 - } - } + "snapping_system": {"snap_threshold_mm": 5.0, "grid_size_mm": 10.0}, + }, } ], - "history": { - "undo_stack": [], - "redo_stack": [], - "max_history": 100 - }, - "asset_manager": { - "reference_counts": {} - } + "history": {"undo_stack": [], "redo_stack": [], "max_history": 100}, + "asset_manager": {"reference_counts": {}}, } @@ -88,9 +75,9 @@ def test_migration(): print(f"\n1. Creating v2.0 project file: {v2_file}") v2_data = create_v2_project_json() - with zipfile.ZipFile(v2_file, 'w', zipfile.ZIP_DEFLATED) as zipf: + with zipfile.ZipFile(v2_file, "w", zipfile.ZIP_DEFLATED) as zipf: project_json = json.dumps(v2_data, indent=2) - zipf.writestr('project.json', project_json) + zipf.writestr("project.json", project_json) print(f" ✓ Created v2.0 project with {len(v2_data['pages'])} page(s)") print(f" ✓ Version: {v2_data['data_version']}") @@ -107,9 +94,9 @@ def test_migration(): print(f"\n3. Verifying migration to v3.0...") # Check project-level fields - assert hasattr(project, 'project_id'), "Missing project_id" - assert hasattr(project, 'created'), "Missing created timestamp" - assert hasattr(project, 'last_modified'), "Missing last_modified timestamp" + assert hasattr(project, "project_id"), "Missing project_id" + assert hasattr(project, "created"), "Missing created timestamp" + assert hasattr(project, "last_modified"), "Missing last_modified timestamp" print(f" ✓ Project has project_id: {project.project_id}") print(f" ✓ Project has created: {project.created}") print(f" ✓ Project has last_modified: {project.last_modified}") @@ -117,20 +104,20 @@ def test_migration(): # Check page-level fields assert len(project.pages) > 0, "No pages in project" page = project.pages[0] - assert hasattr(page, 'uuid'), "Page missing uuid" - assert hasattr(page, 'created'), "Page missing created" - assert hasattr(page, 'last_modified'), "Page missing last_modified" - assert hasattr(page, 'deleted'), "Page missing deleted flag" + assert hasattr(page, "uuid"), "Page missing uuid" + assert hasattr(page, "created"), "Page missing created" + assert hasattr(page, "last_modified"), "Page missing last_modified" + assert hasattr(page, "deleted"), "Page missing deleted flag" print(f" ✓ Page 1 has uuid: {page.uuid}") print(f" ✓ Page 1 has timestamps and deletion tracking") # Check element-level fields assert len(page.layout.elements) > 0, "No elements in page" element = page.layout.elements[0] - assert hasattr(element, 'uuid'), "Element missing uuid" - assert hasattr(element, 'created'), "Element missing created" - assert hasattr(element, 'last_modified'), "Element missing last_modified" - assert hasattr(element, 'deleted'), "Element missing deleted flag" + assert hasattr(element, "uuid"), "Element missing uuid" + assert hasattr(element, "created"), "Element missing created" + assert hasattr(element, "last_modified"), "Element missing last_modified" + assert hasattr(element, "deleted"), "Element missing deleted flag" print(f" ✓ Element has uuid: {element.uuid}") print(f" ✓ Element has timestamps and deletion tracking") @@ -142,14 +129,14 @@ def test_migration(): print(f" ✓ Saved to: {v3_file}") # Verify v3.0 file structure - with zipfile.ZipFile(v3_file, 'r') as zipf: - project_json = zipf.read('project.json').decode('utf-8') + with zipfile.ZipFile(v3_file, "r") as zipf: + project_json = zipf.read("project.json").decode("utf-8") v3_data = json.loads(project_json) - assert v3_data.get('data_version') == "3.0", "Wrong version" - assert 'project_id' in v3_data, "Missing project_id in saved file" - assert 'created' in v3_data, "Missing created in saved file" - assert 'uuid' in v3_data['pages'][0], "Missing page uuid in saved file" + assert v3_data.get("data_version") == "3.0", "Wrong version" + assert "project_id" in v3_data, "Missing project_id in saved file" + assert "created" in v3_data, "Missing created in saved file" + assert "uuid" in v3_data["pages"][0], "Missing page uuid in saved file" print(f" ✓ Saved file version: {v3_data.get('data_version')}") print(f" ✓ All v3.0 fields present in saved file") @@ -164,6 +151,7 @@ def test_migration(): print(f"❌ Migration test FAILED: {e}") print(f"{'=' * 60}\n") import traceback + traceback.print_exc() return False diff --git a/tests/test_models.py b/tests/test_models.py index 8f878b0..264a02e 100755 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -30,15 +30,7 @@ class TestImageData: def test_initialization_with_parameters(self, temp_image_file): """Test ImageData initialization with custom parameters""" - img = ImageData( - image_path=temp_image_file, - x=10.0, - y=20.0, - width=200.0, - height=150.0, - rotation=45.0, - z_index=5 - ) + img = ImageData(image_path=temp_image_file, x=10.0, y=20.0, width=200.0, height=150.0, rotation=45.0, z_index=5) assert img.image_path == temp_image_file assert img.position == (10.0, 20.0) assert img.size == (200.0, 150.0) @@ -53,15 +45,7 @@ class TestImageData: def test_serialization(self, temp_image_file): """Test ImageData serialization to dictionary""" - img = ImageData( - image_path=temp_image_file, - x=15.0, - y=25.0, - width=180.0, - height=120.0, - rotation=30.0, - z_index=3 - ) + img = ImageData(image_path=temp_image_file, x=15.0, y=25.0, width=180.0, height=120.0, rotation=30.0, z_index=3) data = img.serialize() assert data["type"] == "image" @@ -81,7 +65,7 @@ class TestImageData: "rotation": 90.0, "z_index": 7, "image_path": "new_image.jpg", - "crop_info": (0.2, 0.3, 0.7, 0.8) + "crop_info": (0.2, 0.3, 0.7, 0.8), } img.deserialize(data) @@ -118,7 +102,7 @@ class TestImageData: height=200.0, rotation=0, # Visual rotation should be 0 for images z_index=2, - crop_info=(0.1, 0.1, 0.9, 0.9) + crop_info=(0.1, 0.1, 0.9, 0.9), ) original.pil_rotation_90 = 1 # Set PIL rotation to 90 degrees @@ -170,7 +154,7 @@ class TestPlaceholderData: width=150.0, height=100.0, rotation=10.0, - z_index=4 + z_index=4, ) assert placeholder.placeholder_type == "text" assert placeholder.default_content == "Sample" @@ -189,7 +173,7 @@ class TestPlaceholderData: width=200.0, height=150.0, rotation=20.0, - z_index=2 + z_index=2, ) data = placeholder.serialize() @@ -210,7 +194,7 @@ class TestPlaceholderData: "rotation": 45.0, "z_index": 6, "placeholder_type": "text", - "default_content": "Default Text" + "default_content": "Default Text", } placeholder.deserialize(data) @@ -243,7 +227,7 @@ class TestPlaceholderData: width=300.0, height=250.0, rotation=60.0, - z_index=8 + z_index=8, ) data = original.serialize() restored = PlaceholderData() @@ -283,7 +267,7 @@ class TestTextBoxData: width=180.0, height=60.0, rotation=5.0, - z_index=3 + z_index=3, ) assert textbox.text_content == "Hello World" assert textbox.font_settings == font_settings @@ -305,7 +289,7 @@ class TestTextBoxData: width=220.0, height=80.0, rotation=15.0, - z_index=5 + z_index=5, ) data = textbox.serialize() @@ -329,7 +313,7 @@ class TestTextBoxData: "z_index": 7, "text_content": "Deserialized Text", "font_settings": font_settings, - "alignment": "justify" + "alignment": "justify", } textbox.deserialize(data) @@ -366,7 +350,7 @@ class TestTextBoxData: width=320.0, height=120.0, rotation=25.0, - z_index=9 + z_index=9, ) data = original.serialize() restored = TextBoxData() diff --git a/tests/test_mouse_interaction_mixin.py b/tests/test_mouse_interaction_mixin.py index dcea02a..de57774 100755 --- a/tests/test_mouse_interaction_mixin.py +++ b/tests/test_mouse_interaction_mixin.py @@ -28,7 +28,7 @@ class TestMouseInteractionWidget( ElementSelectionMixin, ViewportMixin, UndoableInteractionMixin, - QOpenGLWidget + QOpenGLWidget, ): """Test widget combining mouse interaction with other required mixins""" @@ -49,9 +49,9 @@ class TestMouseInteractionInitialization: qtbot.addWidget(widget) # Should have initialized state - assert hasattr(widget, 'drag_start_pos') - assert hasattr(widget, 'is_dragging') - assert hasattr(widget, 'is_panning') + assert hasattr(widget, "drag_start_pos") + assert hasattr(widget, "is_dragging") + assert hasattr(widget, "is_panning") assert widget.drag_start_pos is None assert widget.is_dragging is False assert widget.is_panning is False @@ -123,8 +123,11 @@ class TestMousePressEvent: # Create image element with crop info element = ImageData( image_path="/test.jpg", - x=50, y=50, width=100, height=100, - crop_info=(0.0, 0.0, 1.0, 1.0) # crop_info is a tuple (x, y, width, height) + x=50, + y=50, + width=100, + height=100, + crop_info=(0.0, 0.0, 1.0, 1.0), # crop_info is a tuple (x, y, width, height) ) event = Mock() @@ -205,7 +208,7 @@ class TestMouseMoveEvent: event.buttons = Mock(return_value=Qt.MouseButton.NoButton) # Mock resize handle detection - widget._get_resize_handle_at = Mock(return_value='bottom-right') + widget._get_resize_handle_at = Mock(return_value="bottom-right") widget._get_element_at = Mock(return_value=element) widget.mouseMoveEvent(event) @@ -295,9 +298,11 @@ class TestMouseMoveEvent: # Create image element with crop info element = ImageData( image_path="/test.jpg", - x=100, y=100, - width=100, height=100, - crop_info=(0.0, 0.0, 1.0, 1.0) # crop_info is a tuple (x, y, width, height) + x=100, + y=100, + width=100, + height=100, + crop_info=(0.0, 0.0, 1.0, 1.0), # crop_info is a tuple (x, y, width, height) ) widget.selected_elements.add(element) @@ -377,9 +382,11 @@ class TestMouseDoubleClickEvent: # Create text element with correct constructor text_element = TextBoxData( text_content="Test", - x=100, y=100, - width=100, height=50, - font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)} + x=100, + y=100, + width=100, + height=50, + font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)}, ) # Mock _edit_text_element method @@ -394,7 +401,7 @@ class TestMouseDoubleClickEvent: QPointF(125, 125), Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, - Qt.KeyboardModifier.NoModifier + Qt.KeyboardModifier.NoModifier, ) widget.mouseDoubleClickEvent(event) @@ -419,7 +426,7 @@ class TestMouseDoubleClickEvent: QPointF(125, 125), Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, - Qt.KeyboardModifier.NoModifier + Qt.KeyboardModifier.NoModifier, ) widget.mouseDoubleClickEvent(event) diff --git a/tests/test_multiselect.py b/tests/test_multiselect.py index f24b7be..df92d26 100755 --- a/tests/test_multiselect.py +++ b/tests/test_multiselect.py @@ -16,6 +16,7 @@ from pyPhotoAlbum.page_layout import PageLayout # Create a minimal test widget class that doesn't require full GLWidget initialization class MultiSelectTestWidget(ElementSelectionMixin, RenderingMixin, QOpenGLWidget): """Widget combining necessary mixins for multiselect testing""" + def __init__(self): super().__init__() self._page_renderers = [] @@ -69,7 +70,7 @@ def test_multiselect_visual_feedback(qtbot): print("\nTest 1: Single selection") widget.selected_elements = {element1} - with patch.object(widget, '_draw_selection_handles') as mock_draw: + with patch.object(widget, "_draw_selection_handles") as mock_draw: # Simulate paintGL call (only the relevant part) for selected_elem in widget.selected_elements: widget._draw_selection_handles(selected_elem) @@ -82,7 +83,7 @@ def test_multiselect_visual_feedback(qtbot): print("\nTest 2: Multiple selection (2 elements)") widget.selected_elements = {element1, element2} - with patch.object(widget, '_draw_selection_handles') as mock_draw: + with patch.object(widget, "_draw_selection_handles") as mock_draw: for selected_elem in widget.selected_elements: widget._draw_selection_handles(selected_elem) @@ -95,7 +96,7 @@ def test_multiselect_visual_feedback(qtbot): print("\nTest 3: Multiple selection (3 elements)") widget.selected_elements = {element1, element2, element3} - with patch.object(widget, '_draw_selection_handles') as mock_draw: + with patch.object(widget, "_draw_selection_handles") as mock_draw: for selected_elem in widget.selected_elements: widget._draw_selection_handles(selected_elem) @@ -108,7 +109,7 @@ def test_multiselect_visual_feedback(qtbot): print("\nTest 4: No selection") widget.selected_elements = set() - with patch.object(widget, '_draw_selection_handles') as mock_draw: + with patch.object(widget, "_draw_selection_handles") as mock_draw: for selected_elem in widget.selected_elements: widget._draw_selection_handles(selected_elem) @@ -120,15 +121,17 @@ def test_multiselect_visual_feedback(qtbot): widget.selected_elements = {element2} # Mock OpenGL functions - with patch('pyPhotoAlbum.gl_widget.glColor3f'), \ - patch('pyPhotoAlbum.gl_widget.glLineWidth'), \ - patch('pyPhotoAlbum.gl_widget.glBegin'), \ - patch('pyPhotoAlbum.gl_widget.glEnd'), \ - patch('pyPhotoAlbum.gl_widget.glVertex2f'), \ - patch('pyPhotoAlbum.gl_widget.glPushMatrix'), \ - patch('pyPhotoAlbum.gl_widget.glPopMatrix'), \ - patch('pyPhotoAlbum.gl_widget.glTranslatef'), \ - patch('pyPhotoAlbum.gl_widget.glRotatef'): + with ( + patch("pyPhotoAlbum.gl_widget.glColor3f"), + patch("pyPhotoAlbum.gl_widget.glLineWidth"), + patch("pyPhotoAlbum.gl_widget.glBegin"), + patch("pyPhotoAlbum.gl_widget.glEnd"), + patch("pyPhotoAlbum.gl_widget.glVertex2f"), + patch("pyPhotoAlbum.gl_widget.glPushMatrix"), + patch("pyPhotoAlbum.gl_widget.glPopMatrix"), + patch("pyPhotoAlbum.gl_widget.glTranslatef"), + patch("pyPhotoAlbum.gl_widget.glRotatef"), + ): # Call the actual method widget._draw_selection_handles(element2) diff --git a/tests/test_page_layout.py b/tests/test_page_layout.py index bb58027..8283024 100755 --- a/tests/test_page_layout.py +++ b/tests/test_page_layout.py @@ -174,7 +174,7 @@ class TestPageLayoutDeserialization: "elements": [], "grid_layout": None, "snapping_system": {}, - "show_snap_lines": False + "show_snap_lines": False, } layout.deserialize(data) @@ -198,7 +198,7 @@ class TestPageLayoutDeserialization: "size": (100, 100), "rotation": 0, "z_index": 0, - "crop_info": (0, 0, 1, 1) + "crop_info": (0, 0, 1, 1), }, { "type": "placeholder", @@ -207,7 +207,7 @@ class TestPageLayoutDeserialization: "position": (30, 40), "size": (80, 80), "rotation": 0, - "z_index": 1 + "z_index": 1, }, { "type": "textbox", @@ -217,9 +217,9 @@ class TestPageLayoutDeserialization: "position": (50, 60), "size": (120, 40), "rotation": 0, - "z_index": 2 - } - ] + "z_index": 2, + }, + ], } layout.deserialize(data) @@ -242,7 +242,7 @@ class TestPageLayoutDeserialization: "size": (100, 100), "rotation": 0, "z_index": 5, # Higher z_index - "crop_info": (0, 0, 1, 1) + "crop_info": (0, 0, 1, 1), }, { "type": "placeholder", @@ -251,9 +251,9 @@ class TestPageLayoutDeserialization: "position": (30, 40), "size": (80, 80), "rotation": 0, - "z_index": 1 # Lower z_index - should be first - } - ] + "z_index": 1, # Lower z_index - should be first + }, + ], } layout.deserialize(data) @@ -269,12 +269,7 @@ class TestPageLayoutDeserialization: data = { "size": (210, 297), "elements": [], - "grid_layout": { - "rows": 2, - "columns": 3, - "spacing": 12.5, - "merged_cells": [(0, 0), (1, 1)] - } + "grid_layout": {"rows": 2, "columns": 3, "spacing": 12.5, "merged_cells": [(0, 0), (1, 1)]}, } layout.deserialize(data) @@ -291,10 +286,7 @@ class TestPageLayoutDeserialization: data = { "size": (210, 297), "elements": [ - { - "type": "unknown_type", - "position": (10, 20) - }, + {"type": "unknown_type", "position": (10, 20)}, { "type": "image", "image_path": "test.jpg", @@ -302,9 +294,9 @@ class TestPageLayoutDeserialization: "size": (100, 100), "rotation": 0, "z_index": 0, - "crop_info": (0, 0, 1, 1) - } - ] + "crop_info": (0, 0, 1, 1), + }, + ], } layout.deserialize(data) @@ -430,12 +422,7 @@ class TestGridLayoutClass: def test_grid_deserialization(self): """Test GridLayout deserialization""" grid = GridLayout() - data = { - "rows": 4, - "columns": 5, - "spacing": 8.5, - "merged_cells": [(1, 2), (3, 3)] - } + data = {"rows": 4, "columns": 5, "spacing": 8.5, "merged_cells": [(1, 2), (3, 3)]} grid.deserialize(data) diff --git a/tests/test_page_navigation_mixin.py b/tests/test_page_navigation_mixin.py index 3314ef7..c4ad904 100755 --- a/tests/test_page_navigation_mixin.py +++ b/tests/test_page_navigation_mixin.py @@ -15,6 +15,7 @@ from pyPhotoAlbum.models import GhostPageData # Create test widget combining necessary mixins class TestPageNavWidget(PageNavigationMixin, ViewportMixin, QOpenGLWidget): """Test widget combining page navigation and viewport mixins""" + pass @@ -149,9 +150,7 @@ class TestGetPagePositions: mock_window.project.pages = [page] # Mock calculate_page_layout_with_ghosts - mock_window.project.calculate_page_layout_with_ghosts = Mock(return_value=[ - ('page', page, 0) - ]) + mock_window.project.calculate_page_layout_with_ghosts = Mock(return_value=[("page", page, 0)]) widget.window = Mock(return_value=mock_window) @@ -159,7 +158,7 @@ class TestGetPagePositions: # Should have one page entry assert len(result) >= 1 - assert result[0][0] == 'page' + assert result[0][0] == "page" assert result[0][1] is page def test_get_page_positions_includes_ghosts(self, qtbot): @@ -177,10 +176,9 @@ class TestGetPagePositions: mock_window.project.pages = [page] # Mock with ghost page - mock_window.project.calculate_page_layout_with_ghosts = Mock(return_value=[ - ('page', page, 0), - ('ghost', None, 1) - ]) + mock_window.project.calculate_page_layout_with_ghosts = Mock( + return_value=[("page", page, 0), ("ghost", None, 1)] + ) widget.window = Mock(return_value=mock_window) @@ -189,8 +187,8 @@ class TestGetPagePositions: # Should have page + ghost assert len(result) >= 2 page_types = [r[0] for r in result] - assert 'page' in page_types - assert 'ghost' in page_types + assert "page" in page_types + assert "ghost" in page_types class TestCheckGhostPageClick: @@ -217,7 +215,7 @@ class TestCheckGhostPageClick: result = widget._check_ghost_page_click(100, 100) assert result is False - @patch('pyPhotoAlbum.page_renderer.PageRenderer') + @patch("pyPhotoAlbum.page_renderer.PageRenderer") def test_check_ghost_page_click_on_ghost(self, mock_page_renderer_class, qtbot): """Test clicking on ghost page creates new page""" widget = TestPageNavWidget() @@ -238,9 +236,7 @@ class TestCheckGhostPageClick: # Mock _get_page_positions to return a ghost ghost = GhostPageData(page_size=(210, 297)) - widget._get_page_positions = Mock(return_value=[ - ('ghost', ghost, 100) - ]) + widget._get_page_positions = Mock(return_value=[("ghost", ghost, 100)]) # Mock PageRenderer to say click is in page mock_renderer_instance = Mock() @@ -257,7 +253,7 @@ class TestCheckGhostPageClick: assert len(mock_window.project.pages) == 1 assert widget.update.called - @patch('pyPhotoAlbum.page_renderer.PageRenderer') + @patch("pyPhotoAlbum.page_renderer.PageRenderer") def test_check_ghost_page_click_outside_ghost(self, mock_page_renderer_class, qtbot): """Test clicking outside ghost page returns False""" widget = TestPageNavWidget() @@ -273,9 +269,7 @@ class TestCheckGhostPageClick: mock_window.project.pages = [] ghost = GhostPageData(page_size=(210, 297)) - widget._get_page_positions = Mock(return_value=[ - ('ghost', ghost, 100) - ]) + widget._get_page_positions = Mock(return_value=[("ghost", ghost, 100)]) # Mock renderer to say click is NOT in page mock_renderer_instance = Mock() diff --git a/tests/test_page_ops_mixin.py b/tests/test_page_ops_mixin.py index 46ef0bb..e47331d 100755 --- a/tests/test_page_ops_mixin.py +++ b/tests/test_page_ops_mixin.py @@ -98,11 +98,7 @@ class TestGetMostVisiblePageIndex: renderer3 = Mock() renderer3.screen_y = 800 - window.gl_widget._page_renderers = [ - (renderer1, page1), - (renderer2, page2), - (renderer3, page3) - ] + window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)] result = window._get_most_visible_page_index() # Page 2 (index 1) should be closest to viewport center @@ -122,10 +118,7 @@ class TestGetMostVisiblePageIndex: renderer_orphan = Mock() renderer_orphan.screen_y = 50 # Closer to center - window.gl_widget._page_renderers = [ - (renderer1, page1), - (renderer_orphan, orphan_page) # Not in project.pages - ] + window.gl_widget._page_renderers = [(renderer1, page1), (renderer_orphan, orphan_page)] # Not in project.pages window.gl_widget.current_page_index = 0 result = window._get_most_visible_page_index() @@ -222,11 +215,7 @@ class TestToggleDoubleSpread: renderer3 = Mock() renderer3.screen_y = 800 - window.gl_widget._page_renderers = [ - (renderer1, page1), - (renderer2, page2), - (renderer3, page3) - ] + window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)] window.toggle_double_spread() @@ -340,11 +329,7 @@ class TestAddPage: renderer3 = Mock() renderer3.screen_y = 800 - window.gl_widget._page_renderers = [ - (renderer1, page1), - (renderer2, page2), - (renderer3, page3) - ] + window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)] window.add_page() @@ -387,11 +372,7 @@ class TestAddPage: renderer3 = Mock() renderer3.screen_y = 1500 - window.gl_widget._page_renderers = [ - (renderer1, page1), - (renderer2, page2), - (renderer3, page3) - ] + window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)] window.add_page() @@ -458,11 +439,7 @@ class TestRemovePage: renderer3 = Mock() renderer3.screen_y = -300 # Page 3 is most visible - window.gl_widget._page_renderers = [ - (renderer1, page1), - (renderer2, page2), - (renderer3, page3) - ] + window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)] window.remove_page() diff --git a/tests/test_page_renderer.py b/tests/test_page_renderer.py index 396737b..02c06fe 100755 --- a/tests/test_page_renderer.py +++ b/tests/test_page_renderer.py @@ -8,164 +8,124 @@ from pyPhotoAlbum.page_renderer import PageRenderer class TestPageRendererCoordinates: """Test coordinate transformation methods""" - + def test_page_to_screen_no_zoom_no_pan(self): """Test page_to_screen conversion with zoom=1.0 and no pan""" renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=100.0, - screen_y=200.0, - dpi=96, - zoom=1.0 + page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0 ) - + # Element at page origin should map to screen_x, screen_y screen_x, screen_y = renderer.page_to_screen(0, 0) assert screen_x == 100.0 assert screen_y == 200.0 - + # Element at (50, 75) should be offset by that amount screen_x, screen_y = renderer.page_to_screen(50, 75) assert screen_x == 150.0 assert screen_y == 275.0 - + def test_page_to_screen_with_zoom(self): """Test page_to_screen conversion with zoom applied""" renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=100.0, - screen_y=200.0, - dpi=96, - zoom=2.0 + page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=2.0 ) - + # With zoom=2.0, distances should be doubled screen_x, screen_y = renderer.page_to_screen(50, 75) assert screen_x == 200.0 # 100 + 50*2 assert screen_y == 350.0 # 200 + 75*2 - + def test_page_to_screen_with_fractional_zoom(self): """Test page_to_screen conversion with fractional zoom""" renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=100.0, - screen_y=200.0, - dpi=96, - zoom=0.5 + page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=0.5 ) - + # With zoom=0.5, distances should be halved screen_x, screen_y = renderer.page_to_screen(100, 150) assert screen_x == 150.0 # 100 + 100*0.5 assert screen_y == 275.0 # 200 + 150*0.5 - + def test_screen_to_page_no_zoom_no_pan(self): """Test screen_to_page conversion with zoom=1.0 and no pan""" renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=100.0, - screen_y=200.0, - dpi=96, - zoom=1.0 + page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0 ) - + # Screen position at screen_x, screen_y should map to page origin page_x, page_y = renderer.screen_to_page(100.0, 200.0) assert page_x == 0.0 assert page_y == 0.0 - + # Screen position offset should map to same offset in page coords page_x, page_y = renderer.screen_to_page(150.0, 275.0) assert page_x == 50.0 assert page_y == 75.0 - + def test_screen_to_page_with_zoom(self): """Test screen_to_page conversion with zoom applied""" renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=100.0, - screen_y=200.0, - dpi=96, - zoom=2.0 + page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=2.0 ) - + # With zoom=2.0, screen distances should be divided by 2 to get page coords page_x, page_y = renderer.screen_to_page(200.0, 350.0) assert page_x == 50.0 # (200-100)/2 assert page_y == 75.0 # (350-200)/2 - + def test_roundtrip_conversion_no_zoom(self): """Test that page->screen->page conversion is accurate with no zoom""" renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=100.0, - screen_y=200.0, - dpi=96, - zoom=1.0 + page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0 ) - + # Start with page coordinates orig_page_x, orig_page_y = 123.45, 678.90 - + # Convert to screen and back screen_x, screen_y = renderer.page_to_screen(orig_page_x, orig_page_y) page_x, page_y = renderer.screen_to_page(screen_x, screen_y) - + # Should get back the original values assert abs(page_x - orig_page_x) < 0.001 assert abs(page_y - orig_page_y) < 0.001 - + def test_roundtrip_conversion_with_zoom(self): """Test that page->screen->page conversion is accurate with zoom""" renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=100.0, - screen_y=200.0, - dpi=96, - zoom=1.5 + page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.5 ) - + # Start with page coordinates orig_page_x, orig_page_y = 123.45, 678.90 - + # Convert to screen and back screen_x, screen_y = renderer.page_to_screen(orig_page_x, orig_page_y) page_x, page_y = renderer.screen_to_page(screen_x, screen_y) - + # Should get back the original values (with floating point tolerance) assert abs(page_x - orig_page_x) < 0.001 assert abs(page_y - orig_page_y) < 0.001 - + def test_roundtrip_conversion_extreme_zoom(self): """Test coordinate conversion with extreme zoom levels""" for zoom in [0.1, 0.5, 1.0, 2.0, 5.0]: renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=50.0, - screen_y=100.0, - dpi=96, - zoom=zoom + page_width_mm=210.0, page_height_mm=297.0, screen_x=50.0, screen_y=100.0, dpi=96, zoom=zoom ) - + orig_page_x, orig_page_y = 250.0, 400.0 screen_x, screen_y = renderer.page_to_screen(orig_page_x, orig_page_y) page_x, page_y = renderer.screen_to_page(screen_x, screen_y) - + assert abs(page_x - orig_page_x) < 0.001 assert abs(page_y - orig_page_y) < 0.001 class TestPageRendererBounds: """Test page bounds and point detection""" - + def test_is_point_in_page_inside(self): """Test is_point_in_page for points inside the page""" renderer = PageRenderer( @@ -174,108 +134,85 @@ class TestPageRendererBounds: screen_x=100.0, screen_y=200.0, dpi=96, - zoom=1.0 + zoom=1.0, ) - + # Calculate page dimensions in pixels page_width_px = 210.0 * 96 / 25.4 # ~794 pixels page_height_px = 297.0 * 96 / 25.4 # ~1123 pixels - + # Point in center should be inside center_x = 100.0 + page_width_px / 2 center_y = 200.0 + page_height_px / 2 assert renderer.is_point_in_page(center_x, center_y) - + # Point at origin should be inside assert renderer.is_point_in_page(100.0, 200.0) - + # Point at bottom-right corner should be inside - assert renderer.is_point_in_page( - 100.0 + page_width_px, - 200.0 + page_height_px - ) - + assert renderer.is_point_in_page(100.0 + page_width_px, 200.0 + page_height_px) + def test_is_point_in_page_outside(self): """Test is_point_in_page for points outside the page""" renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=100.0, - screen_y=200.0, - dpi=96, - zoom=1.0 + page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0 ) - + # Point before page start assert not renderer.is_point_in_page(50.0, 150.0) - + # Point way beyond page assert not renderer.is_point_in_page(2000.0, 2000.0) - + # Point to the left of page assert not renderer.is_point_in_page(50.0, 500.0) - + # Point above page assert not renderer.is_point_in_page(500.0, 150.0) - + def test_is_point_in_page_with_zoom(self): """Test is_point_in_page with different zoom levels""" for zoom in [0.5, 1.0, 2.0]: renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=100.0, - screen_y=200.0, - dpi=96, - zoom=zoom + page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=zoom ) - + # Center of page should always be inside regardless of zoom page_width_px = 210.0 * 96 / 25.4 page_height_px = 297.0 * 96 / 25.4 center_x = 100.0 + (page_width_px * zoom) / 2 center_y = 200.0 + (page_height_px * zoom) / 2 assert renderer.is_point_in_page(center_x, center_y) - + def test_get_page_bounds_screen(self): """Test get_page_bounds_screen returns correct screen coordinates""" renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=100.0, - screen_y=200.0, - dpi=96, - zoom=1.5 + page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.5 ) - + x, y, w, h = renderer.get_page_bounds_screen() - + assert x == 100.0 assert y == 200.0 - + # Width and height should be scaled by zoom page_width_px = 210.0 * 96 / 25.4 page_height_px = 297.0 * 96 / 25.4 assert abs(w - page_width_px * 1.5) < 0.1 assert abs(h - page_height_px * 1.5) < 0.1 - + def test_get_page_bounds_page(self): """Test get_page_bounds_page returns correct page-local coordinates""" renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=100.0, - screen_y=200.0, - dpi=96, - zoom=1.5 + page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.5 ) - + x, y, w, h = renderer.get_page_bounds_page() - + # Origin should be at 0,0 in page-local coordinates assert x == 0.0 assert y == 0.0 - + # Width and height should NOT be affected by zoom (page-local coords) page_width_px = 210.0 * 96 / 25.4 page_height_px = 297.0 * 96 / 25.4 @@ -285,22 +222,17 @@ class TestPageRendererBounds: class TestPageRendererSubPages: """Test sub-page detection for facing pages""" - + def test_get_sub_page_at_single_page(self): """Test that get_sub_page_at returns None for single pages""" renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=100.0, - screen_y=200.0, - dpi=96, - zoom=1.0 + page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0 ) - + # For non-facing pages, should return None result = renderer.get_sub_page_at(500.0, is_facing_page=False) assert result is None - + def test_get_sub_page_at_facing_page_left(self): """Test get_sub_page_at for left side of facing page""" renderer = PageRenderer( @@ -309,17 +241,17 @@ class TestPageRendererSubPages: screen_x=100.0, screen_y=200.0, dpi=96, - zoom=1.0 + zoom=1.0, ) - + # Calculate center line page_width_px = 420.0 * 96 / 25.4 center_x = 100.0 + page_width_px / 2 - + # Point before center should be 'left' result = renderer.get_sub_page_at(center_x - 10, is_facing_page=True) - assert result == 'left' - + assert result == "left" + def test_get_sub_page_at_facing_page_right(self): """Test get_sub_page_at for right side of facing page""" renderer = PageRenderer( @@ -328,21 +260,21 @@ class TestPageRendererSubPages: screen_x=100.0, screen_y=200.0, dpi=96, - zoom=1.0 + zoom=1.0, ) - + # Calculate center line page_width_px = 420.0 * 96 / 25.4 center_x = 100.0 + page_width_px / 2 - + # Point after center should be 'right' result = renderer.get_sub_page_at(center_x + 10, is_facing_page=True) - assert result == 'right' + assert result == "right" class TestPageRendererDimensions: """Test page dimension calculations""" - + def test_page_dimensions_calculated_correctly(self): """Test that page dimensions are calculated correctly from mm to pixels""" renderer = PageRenderer( @@ -351,113 +283,86 @@ class TestPageRendererDimensions: screen_x=0.0, screen_y=0.0, dpi=96, - zoom=1.0 + zoom=1.0, ) - + # A4 at 96 DPI expected_width = 210.0 * 96 / 25.4 # ~794 pixels expected_height = 297.0 * 96 / 25.4 # ~1123 pixels - + assert abs(renderer.page_width_px - expected_width) < 0.1 assert abs(renderer.page_height_px - expected_height) < 0.1 - + def test_screen_dimensions_with_zoom(self): """Test that screen dimensions account for zoom""" - renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=0.0, - screen_y=0.0, - dpi=96, - zoom=2.0 - ) - + renderer = PageRenderer(page_width_mm=210.0, page_height_mm=297.0, screen_x=0.0, screen_y=0.0, dpi=96, zoom=2.0) + # Screen dimensions should be doubled due to zoom expected_width = (210.0 * 96 / 25.4) * 2.0 expected_height = (297.0 * 96 / 25.4) * 2.0 - + assert abs(renderer.screen_width - expected_width) < 0.1 assert abs(renderer.screen_height - expected_height) < 0.1 - + def test_different_dpi_values(self): """Test page dimensions with different DPI values""" dpi_values = [72, 96, 150, 300] - + for dpi in dpi_values: renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=0.0, - screen_y=0.0, - dpi=dpi, - zoom=1.0 + page_width_mm=210.0, page_height_mm=297.0, screen_x=0.0, screen_y=0.0, dpi=dpi, zoom=1.0 ) - + expected_width = 210.0 * dpi / 25.4 expected_height = 297.0 * dpi / 25.4 - + assert abs(renderer.page_width_px - expected_width) < 0.1 assert abs(renderer.page_height_px - expected_height) < 0.1 class TestPageRendererEdgeCases: """Test edge cases and boundary conditions""" - + def test_zero_coordinates(self): """Test handling of zero coordinates""" renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=100.0, - screen_y=200.0, - dpi=96, - zoom=1.0 + page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0 ) - + screen_x, screen_y = renderer.page_to_screen(0, 0) assert screen_x == 100.0 assert screen_y == 200.0 - + page_x, page_y = renderer.screen_to_page(100.0, 200.0) assert page_x == 0.0 assert page_y == 0.0 - + def test_negative_page_coordinates(self): """Test handling of negative page coordinates""" renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=100.0, - screen_y=200.0, - dpi=96, - zoom=1.0 + page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0 ) - + # Negative page coordinates should still convert correctly screen_x, screen_y = renderer.page_to_screen(-50, -75) assert screen_x == 50.0 assert screen_y == 125.0 - + # And back again page_x, page_y = renderer.screen_to_page(50.0, 125.0) assert page_x == -50.0 assert page_y == -75.0 - + def test_very_large_coordinates(self): """Test handling of very large coordinates""" renderer = PageRenderer( - page_width_mm=210.0, - page_height_mm=297.0, - screen_x=100.0, - screen_y=200.0, - dpi=96, - zoom=1.0 + page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0 ) - + large_x, large_y = 10000.0, 20000.0 - + screen_x, screen_y = renderer.page_to_screen(large_x, large_y) page_x, page_y = renderer.screen_to_page(screen_x, screen_y) - + assert abs(page_x - large_x) < 0.001 assert abs(page_y - large_y) < 0.001 diff --git a/tests/test_page_setup_dialog.py b/tests/test_page_setup_dialog.py index 08e0ea6..d780715 100644 --- a/tests/test_page_setup_dialog.py +++ b/tests/test_page_setup_dialog.py @@ -190,16 +190,16 @@ class TestPageSetupDialog: values = dialog.get_values() # Check all values returned - assert values['selected_index'] == 0 - assert values['selected_page'] == page - assert values['is_cover'] is True - assert values['paper_thickness_mm'] == 0.15 - assert values['cover_bleed_mm'] == 5.0 - assert values['width_mm'] == 200 - assert values['height_mm'] == 280 - assert values['working_dpi'] == 150 - assert values['export_dpi'] == 600 - assert values['set_as_default'] is True + assert values["selected_index"] == 0 + assert values["selected_page"] == page + assert values["is_cover"] is True + assert values["paper_thickness_mm"] == 0.15 + assert values["cover_bleed_mm"] == 5.0 + assert values["width_mm"] == 200 + assert values["height_mm"] == 280 + assert values["working_dpi"] == 150 + assert values["export_dpi"] == 600 + assert values["set_as_default"] is True def test_dialog_page_change_updates_values(self, qtbot): """Test changing selected page updates displayed values""" @@ -240,14 +240,14 @@ class TestDialogMixin: # Create mock dialog with get_values as a proper method mock_dialog = MagicMock(spec=QDialog) mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted) - mock_dialog.get_values = Mock(return_value={'test': 'value'}) + mock_dialog.get_values = Mock(return_value={"test": "value"}) # Mock dialog class mock_dialog_class = Mock(return_value=mock_dialog) result = window.create_dialog(mock_dialog_class) - assert result == {'test': 'value'} + assert result == {"test": "value"} mock_dialog.exec.assert_called_once() def test_dialog_mixin_create_dialog_rejected(self, qtbot): @@ -283,7 +283,7 @@ class TestDialogMixin: # Create mock dialog with get_values as a proper method mock_dialog = MagicMock(spec=QDialog) mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted) - mock_dialog.get_values = Mock(return_value={'test': 'value'}) + mock_dialog.get_values = Mock(return_value={"test": "value"}) # Mock dialog class mock_dialog_class = Mock(return_value=mock_dialog) @@ -294,7 +294,7 @@ class TestDialogMixin: result = window.show_dialog(mock_dialog_class, on_accept=callback) assert result is True - callback.assert_called_once_with({'test': 'value'}) + callback.assert_called_once_with({"test": "value"}) class TestDialogActionDecorator: @@ -362,7 +362,7 @@ class TestDialogMixinEdgeCases: mock_dialog = MagicMock(spec=QDialog) mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted) - mock_dialog.get_values = Mock(return_value={'data': 'test'}) + mock_dialog.get_values = Mock(return_value={"data": "test"}) mock_dialog_class = Mock(return_value=mock_dialog) @@ -370,7 +370,7 @@ class TestDialogMixinEdgeCases: # Verify setWindowTitle was called mock_dialog.setWindowTitle.assert_called_once_with("Custom Title") - assert result == {'data': 'test'} + assert result == {"data": "test"} def test_show_dialog_rejected(self, qtbot): """Test show_dialog when user rejects dialog""" @@ -543,16 +543,16 @@ class TestPageSetupIntegration: # Create mock values that would come from dialog values = { - 'selected_index': 0, - 'selected_page': window.project.pages[0], - 'is_cover': False, - 'paper_thickness_mm': 0.15, - 'cover_bleed_mm': 5.0, - 'width_mm': 200, - 'height_mm': 280, - 'working_dpi': 150, - 'export_dpi': 600, - 'set_as_default': True + "selected_index": 0, + "selected_page": window.project.pages[0], + "is_cover": False, + "paper_thickness_mm": 0.15, + "cover_bleed_mm": 5.0, + "width_mm": 200, + "height_mm": 280, + "working_dpi": 150, + "export_dpi": 600, + "set_as_default": True, } # Access the unwrapped function to test business logic directly @@ -564,7 +564,7 @@ class TestPageSetupIntegration: original_func = window.page_setup # Decorators return wrappers, but we can call them with values directly # by accessing the innermost wrapped function - while hasattr(original_func, '__wrapped__'): + while hasattr(original_func, "__wrapped__"): original_func = original_func.__wrapped__ # If no __wrapped__, the decorator system is different @@ -575,7 +575,7 @@ class TestPageSetupIntegration: # Get the undecorated method from the class undecorated_page_setup = page_ops.PageOperationsMixin.page_setup # Find the innermost function - while hasattr(undecorated_page_setup, '__wrapped__'): + while hasattr(undecorated_page_setup, "__wrapped__"): undecorated_page_setup = undecorated_page_setup.__wrapped__ # Call the business logic directly @@ -635,22 +635,23 @@ class TestPageSetupIntegration: # Test designating first page as cover values = { - 'selected_index': 0, - 'selected_page': window.project.pages[0], - 'is_cover': True, # Designate as cover - 'paper_thickness_mm': 0.1, - 'cover_bleed_mm': 3.0, - 'width_mm': 210, - 'height_mm': 297, - 'working_dpi': 96, - 'export_dpi': 300, - 'set_as_default': False + "selected_index": 0, + "selected_page": window.project.pages[0], + "is_cover": True, # Designate as cover + "paper_thickness_mm": 0.1, + "cover_bleed_mm": 3.0, + "width_mm": 210, + "height_mm": 297, + "working_dpi": 96, + "export_dpi": 300, + "set_as_default": False, } # Get the undecorated method from pyPhotoAlbum.mixins.operations import page_ops + undecorated_page_setup = page_ops.PageOperationsMixin.page_setup - while hasattr(undecorated_page_setup, '__wrapped__'): + while hasattr(undecorated_page_setup, "__wrapped__"): undecorated_page_setup = undecorated_page_setup.__wrapped__ # Mock update_cover_dimensions @@ -706,21 +707,22 @@ class TestPageSetupIntegration: # Test changing double spread page size values = { - 'selected_index': 0, - 'selected_page': window.project.pages[0], - 'is_cover': False, - 'paper_thickness_mm': 0.1, - 'cover_bleed_mm': 3.0, - 'width_mm': 200, # New base width - 'height_mm': 280, # New height - 'working_dpi': 96, - 'export_dpi': 300, - 'set_as_default': False + "selected_index": 0, + "selected_page": window.project.pages[0], + "is_cover": False, + "paper_thickness_mm": 0.1, + "cover_bleed_mm": 3.0, + "width_mm": 200, # New base width + "height_mm": 280, # New height + "working_dpi": 96, + "export_dpi": 300, + "set_as_default": False, } from pyPhotoAlbum.mixins.operations import page_ops + undecorated_page_setup = page_ops.PageOperationsMixin.page_setup - while hasattr(undecorated_page_setup, '__wrapped__'): + while hasattr(undecorated_page_setup, "__wrapped__"): undecorated_page_setup = undecorated_page_setup.__wrapped__ undecorated_page_setup(window, values) diff --git a/tests/test_page_setup_dialog_mocked.py b/tests/test_page_setup_dialog_mocked.py index c924db4..fcc7982 100644 --- a/tests/test_page_setup_dialog_mocked.py +++ b/tests/test_page_setup_dialog_mocked.py @@ -26,7 +26,7 @@ class TestPageSetupDialogWithMocks: # We can verify the class signature and that it would accept these params # This is a structural test rather than a full initialization test - assert hasattr(PageSetupDialog, '__init__') + assert hasattr(PageSetupDialog, "__init__") # The actual widget creation tests are in test_page_setup_dialog.py # using qtbot which handles Qt properly @@ -44,7 +44,7 @@ class TestPageSetupDialogWithMocks: project.pages = [page1, page2] # Mock the dialog instance - with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None): + with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None): dialog = PageSetupDialog(None, None, 0) # Manually set required attributes @@ -88,7 +88,7 @@ class TestPageSetupDialogWithMocks: page = Page(layout=PageLayout(width=210, height=297), page_number=1) project.pages = [page] - with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None): + with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None): dialog = PageSetupDialog(None, None, 0) dialog.project = project dialog._cover_group = Mock() @@ -112,11 +112,11 @@ class TestPageSetupDialogWithMocks: # Create 3 content pages (not covers) for i in range(3): - page = Page(layout=PageLayout(width=210, height=297), page_number=i+1) + page = Page(layout=PageLayout(width=210, height=297), page_number=i + 1) page.is_cover = False project.pages.append(page) - with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None): + with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None): dialog = PageSetupDialog(None, None, 0) dialog.project = project dialog.cover_checkbox = Mock() @@ -158,7 +158,7 @@ class TestPageSetupDialogWithMocks: page = Page(layout=PageLayout(width=210, height=297), page_number=1) project.pages = [page] - with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None): + with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None): dialog = PageSetupDialog(None, None, 0) dialog.project = project @@ -194,16 +194,16 @@ class TestPageSetupDialogWithMocks: values = dialog.get_values() # Verify all values were extracted - assert values['selected_index'] == 0 - assert values['selected_page'] == page - assert values['is_cover'] is True - assert values['paper_thickness_mm'] == 0.15 - assert values['cover_bleed_mm'] == 5.0 - assert values['width_mm'] == 200.0 - assert values['height_mm'] == 280.0 - assert values['working_dpi'] == 150 - assert values['export_dpi'] == 600 - assert values['set_as_default'] is True + assert values["selected_index"] == 0 + assert values["selected_page"] == page + assert values["is_cover"] is True + assert values["paper_thickness_mm"] == 0.15 + assert values["cover_bleed_mm"] == 5.0 + assert values["width_mm"] == 200.0 + assert values["height_mm"] == 280.0 + assert values["working_dpi"] == 150 + assert values["export_dpi"] == 600 + assert values["set_as_default"] is True def test_cover_page_width_display(self): """Test cover page shows full width, not base width""" @@ -217,7 +217,7 @@ class TestPageSetupDialogWithMocks: page.is_cover = True project.pages = [page] - with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None): + with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None): dialog = PageSetupDialog(None, None, 0) dialog.project = project dialog._cover_group = Mock() @@ -259,7 +259,7 @@ class TestDialogMixinMocked: # Mock dialog class mock_dialog_instance = Mock() mock_dialog_instance.exec.return_value = 1 # Accepted - mock_dialog_instance.get_values.return_value = {'key': 'value'} + mock_dialog_instance.get_values.return_value = {"key": "value"} mock_dialog_class = Mock(return_value=mock_dialog_instance) @@ -279,7 +279,7 @@ class TestDialogMixinMocked: mock_dialog_instance.get_values.assert_called_once() # Verify result - assert result == {'key': 'value'} + assert result == {"key": "value"} def test_show_dialog_with_callback_flow(self): """Test show_dialog method with callback""" @@ -293,7 +293,7 @@ class TestDialogMixinMocked: # Mock dialog mock_dialog_instance = Mock() mock_dialog_instance.exec.return_value = 1 # Accepted - mock_dialog_instance.get_values.return_value = {'data': 'test'} + mock_dialog_instance.get_values.return_value = {"data": "test"} mock_dialog_class = Mock(return_value=mock_dialog_instance) @@ -304,7 +304,7 @@ class TestDialogMixinMocked: result = window.show_dialog(mock_dialog_class, on_accept=callback, param="value") # Verify callback was called with dialog values - callback.assert_called_once_with({'data': 'test'}) + callback.assert_called_once_with({"data": "test"}) # Verify result assert result is True @@ -346,7 +346,7 @@ class TestDialogActionDecoratorMocked: # Mock dialog instance mock_dialog = Mock() mock_dialog.exec.return_value = QDialog.DialogCode.Accepted # Accepted - mock_dialog.get_values.return_value = {'test': 'data'} + mock_dialog.get_values.return_value = {"test": "data"} # Mock dialog class mock_dialog_cls = Mock(return_value=mock_dialog) @@ -354,7 +354,7 @@ class TestDialogActionDecoratorMocked: # Create decorated function @dialog_action(dialog_class=mock_dialog_cls, requires_pages=True) def test_function(self, values): - return values['test'] + return values["test"] # Mock instance with required attributes instance = Mock() @@ -375,7 +375,7 @@ class TestDialogActionDecoratorMocked: mock_dialog.get_values.assert_called_once() # Verify original function received values - assert result == 'data' + assert result == "data" def test_decorator_returns_early_when_no_pages(self): """Test decorator returns early when pages required but not present""" @@ -407,7 +407,7 @@ class TestDialogActionDecoratorMocked: mock_dialog = Mock() mock_dialog.exec.return_value = 1 - mock_dialog.get_values.return_value = {'key': 'val'} + mock_dialog.get_values.return_value = {"key": "val"} mock_dialog_cls = Mock(return_value=mock_dialog) @@ -427,8 +427,8 @@ class TestDialogActionDecoratorMocked: mock_dialog_cls.assert_called_once() # Verify result - assert result == {'key': 'val'} + assert result == {"key": "val"} -if __name__ == '__main__': - pytest.main([__file__, '-v']) +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_pdf_export.py b/tests/test_pdf_export.py index cd729bb..228f86b 100755 --- a/tests/test_pdf_export.py +++ b/tests/test_pdf_export.py @@ -15,27 +15,27 @@ def test_pdf_exporter_basic(): # Create a simple project project = Project("Test Project") project.page_size_mm = (210, 297) # A4 - + # Add a single page page = Page(page_number=1, is_double_spread=False) project.add_page(page) - + # Export to temporary file - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: tmp_path = tmp.name - + try: exporter = PDFExporter(project) success, warnings = exporter.export(tmp_path) - + assert success, f"Export failed: {warnings}" assert os.path.exists(tmp_path), "PDF file was not created" assert os.path.getsize(tmp_path) > 0, "PDF file is empty" - + print(f"✓ Basic PDF export successful: {tmp_path}") if warnings: print(f" Warnings: {warnings}") - + finally: if os.path.exists(tmp_path): os.remove(tmp_path) @@ -45,26 +45,26 @@ def test_pdf_exporter_double_spread(): """Test PDF export with double-page spread""" project = Project("Test Spread Project") project.page_size_mm = (210, 297) # A4 - + # Add a double-page spread spread_page = Page(page_number=1, is_double_spread=True) project.add_page(spread_page) - + # Export to temporary file - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: tmp_path = tmp.name - + try: exporter = PDFExporter(project) success, warnings = exporter.export(tmp_path) - + assert success, f"Export failed: {warnings}" assert os.path.exists(tmp_path), "PDF file was not created" - + print(f"✓ Double-spread PDF export successful: {tmp_path}") if warnings: print(f" Warnings: {warnings}") - + finally: if os.path.exists(tmp_path): os.remove(tmp_path) @@ -83,14 +83,17 @@ def test_pdf_exporter_with_text(): text_content="Hello, World!", font_settings={"family": "Helvetica", "size": 24, "color": (0, 0, 0)}, alignment="center", - x=50, y=50, width=100, height=30 + x=50, + y=50, + width=100, + height=30, ) page.layout.add_element(text_box) project.add_page(page) # Export to temporary file - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: tmp_path = tmp.name try: @@ -143,7 +146,7 @@ def test_pdf_text_position_and_size(): x=text_box_x_px, y=text_box_y_px, width=text_box_width_px, - height=text_box_height_px + height=text_box_height_px, ) page.layout.add_element(text_box) project.add_page(page) @@ -168,7 +171,7 @@ def test_pdf_text_position_and_size(): expected_font_size_pt = font_size_px * 25.4 / dpi * MM_TO_POINTS # Export to temporary file - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: tmp_path = tmp.name try: @@ -186,16 +189,20 @@ def test_pdf_text_position_and_size(): # Get the first character's position and font size first_char = chars[0] - text_x = first_char['x0'] - text_y_baseline = first_char['y0'] # This is the baseline y position - actual_font_size = first_char['size'] + text_x = first_char["x0"] + text_y_baseline = first_char["y0"] # This is the baseline y position + actual_font_size = first_char["size"] print(f"\nText Position Analysis:") - print(f" Text box (in pixels at {dpi} DPI): x={text_box_x_px}, y={text_box_y_px}, " - f"w={text_box_width_px}, h={text_box_height_px}") - print(f" Text box (in PDF points): x={text_box_x_pt:.1f}, " - f"y_bottom={text_box_y_pt_bottom:.1f}, y_top={text_box_y_pt_top:.1f}, " - f"height={text_box_height_pt:.1f}") + print( + f" Text box (in pixels at {dpi} DPI): x={text_box_x_px}, y={text_box_y_px}, " + f"w={text_box_width_px}, h={text_box_height_px}" + ) + print( + f" Text box (in PDF points): x={text_box_x_pt:.1f}, " + f"y_bottom={text_box_y_pt_bottom:.1f}, y_top={text_box_y_pt_top:.1f}, " + f"height={text_box_height_pt:.1f}" + ) print(f" Font size (pixels): {font_size_px}") print(f" Expected font size (points): {expected_font_size_pt:.1f}") print(f" Actual font size (points): {actual_font_size:.1f}") @@ -213,8 +220,7 @@ def test_pdf_text_position_and_size(): # Verify text X position is near the left edge of the text box x_diff = abs(text_x - text_box_x_pt) assert x_diff < 5.0, ( - f"Text X position mismatch: expected ~{text_box_x_pt:.1f}, " - f"got {text_x:.1f} (diff: {x_diff:.1f}pt)" + f"Text X position mismatch: expected ~{text_box_x_pt:.1f}, " f"got {text_x:.1f} (diff: {x_diff:.1f}pt)" ) # Verify text Y baseline is INSIDE the text box (not above it) @@ -280,7 +286,7 @@ def test_pdf_text_wrapping(): x=text_box_x_px, y=text_box_y_px, width=text_box_width_px, - height=text_box_height_px + height=text_box_height_px, ) page.layout.add_element(text_box) project.add_page(page) @@ -296,7 +302,7 @@ def test_pdf_text_wrapping(): text_box_right_pt = text_box_x_pt + text_box_width_pt # Export to temporary file - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: tmp_path = tmp.name try: @@ -313,7 +319,7 @@ def test_pdf_text_wrapping(): assert len(chars) > 0, "No text found in PDF" # Get all unique Y positions (lines) - y_positions = sorted(set(round(c['top'], 1) for c in chars)) + y_positions = sorted(set(round(c["top"], 1) for c in chars)) print(f"\nText Wrapping Analysis:") print(f" Text box width: {text_box_width_pt:.1f}pt") @@ -322,21 +328,19 @@ def test_pdf_text_wrapping(): print(f" Line Y positions: {y_positions[:5]}...") # Show first 5 # Verify text wrapped to multiple lines - assert len(y_positions) > 1, ( - f"Text should wrap to multiple lines but only found {len(y_positions)} line(s)" - ) + assert len(y_positions) > 1, f"Text should wrap to multiple lines but only found {len(y_positions)} line(s)" # Verify all characters are within box width (with small tolerance) tolerance = 5.0 # Small tolerance for rounding for char in chars: - char_x = char['x0'] - char_right = char['x1'] - assert char_x >= text_box_x_pt - tolerance, ( - f"Character '{char['text']}' at x={char_x:.1f} is left of box start {text_box_x_pt:.1f}" - ) - assert char_right <= text_box_right_pt + tolerance, ( - f"Character '{char['text']}' ends at x={char_right:.1f} which exceeds box right {text_box_right_pt:.1f}" - ) + char_x = char["x0"] + char_right = char["x1"] + assert ( + char_x >= text_box_x_pt - tolerance + ), f"Character '{char['text']}' at x={char_x:.1f} is left of box start {text_box_x_pt:.1f}" + assert ( + char_right <= text_box_right_pt + tolerance + ), f"Character '{char['text']}' ends at x={char_right:.1f} which exceeds box right {text_box_right_pt:.1f}" print(f" All characters within box width: ✓") print(f"\n✓ Text wrapping test passed!") @@ -354,28 +358,28 @@ def test_pdf_exporter_facing_pages_alignment(): # Add single page (page 1) page1 = Page(page_number=1, is_double_spread=False) project.add_page(page1) - + # Add double spread (should start on page 2, which requires blank insert) # Since page 1 is odd, a blank page should be inserted, making the spread pages 2-3 spread = Page(page_number=2, is_double_spread=True) project.add_page(spread) - + # Export to temporary file - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: tmp_path = tmp.name - + try: exporter = PDFExporter(project) success, warnings = exporter.export(tmp_path) - + assert success, f"Export failed: {warnings}" assert os.path.exists(tmp_path), "PDF file was not created" - + print(f"✓ Facing pages alignment successful: {tmp_path}") print(f" Expected: Page 1 (single), blank page, Pages 2-3 (spread)") if warnings: print(f" Warnings: {warnings}") - + finally: if os.path.exists(tmp_path): os.remove(tmp_path) @@ -385,34 +389,31 @@ def test_pdf_exporter_missing_image(): """Test PDF export with missing image (should warn but not fail)""" project = Project("Test Missing Image") project.page_size_mm = (210, 297) - + # Create page with image that doesn't exist page = Page(page_number=1, is_double_spread=False) - + # Add image with non-existent path - image = ImageData( - image_path="/nonexistent/path/to/image.jpg", - x=50, y=50, width=100, height=100 - ) + image = ImageData(image_path="/nonexistent/path/to/image.jpg", x=50, y=50, width=100, height=100) page.layout.add_element(image) - + project.add_page(page) - + # Export to temporary file - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: tmp_path = tmp.name - + try: exporter = PDFExporter(project) success, warnings = exporter.export(tmp_path) - + assert success, "Export should succeed even with missing images" assert len(warnings) > 0, "Should have warnings for missing image" assert "not found" in warnings[0].lower(), "Warning should mention missing image" - + print(f"✓ Missing image handling successful: {tmp_path}") print(f" Warnings: {warnings}") - + finally: if os.path.exists(tmp_path): os.remove(tmp_path) @@ -422,65 +423,59 @@ def test_pdf_exporter_spanning_image(): """Test PDF export with image spanning across center line of double spread""" import tempfile from PIL import Image as PILImage - + project = Project("Test Spanning Image") project.page_size_mm = (210, 297) # A4 project.working_dpi = 96 # Standard DPI - + # Create a test image (solid color for easy verification) - test_img = PILImage.new('RGB', (400, 200), color='red') - + test_img = PILImage.new("RGB", (400, 200), color="red") + # Save test image to temporary file - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp: + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp: img_path = img_tmp.name test_img.save(img_path) - + try: # Create a double-page spread spread_page = Page(page_number=1, is_double_spread=True) - + # Calculate center position in pixels (for a 210mm page width at 96 DPI) # Spread width is 2 * 210mm = 420mm spread_width_px = 420 * 96 / 25.4 # ~1587 pixels center_px = spread_width_px / 2 # ~794 pixels - + # Add an image that spans across the center # Position it so it overlaps the center line image_width_px = 400 image_x_px = center_px - 200 # Start 200px before center, end 200px after - - spanning_image = ImageData( - image_path=img_path, - x=image_x_px, - y=100, - width=image_width_px, - height=200 - ) + + spanning_image = ImageData(image_path=img_path, x=image_x_px, y=100, width=image_width_px, height=200) spread_page.layout.add_element(spanning_image) - + project.add_page(spread_page) - + # Export to temporary PDF - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp: + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp: pdf_path = pdf_tmp.name - + try: exporter = PDFExporter(project) success, warnings = exporter.export(pdf_path) - + assert success, f"Export failed: {warnings}" assert os.path.exists(pdf_path), "PDF file was not created" - + print(f"✓ Spanning image export successful: {pdf_path}") print(f" Image spans from {image_x_px:.1f}px to {image_x_px + image_width_px:.1f}px") print(f" Center line at {center_px:.1f}px") if warnings: print(f" Warnings: {warnings}") - + finally: if os.path.exists(pdf_path): os.remove(pdf_path) - + finally: if os.path.exists(img_path): os.remove(img_path) @@ -490,71 +485,61 @@ def test_pdf_exporter_multiple_spanning_elements(): """Test PDF export with multiple images spanning the center line""" import tempfile from PIL import Image as PILImage - + project = Project("Test Multiple Spanning") project.page_size_mm = (210, 297) # A4 project.working_dpi = 96 - + # Create test images - test_img1 = PILImage.new('RGB', (300, 150), color='blue') - test_img2 = PILImage.new('RGB', (250, 200), color='green') - - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp1: + test_img1 = PILImage.new("RGB", (300, 150), color="blue") + test_img2 = PILImage.new("RGB", (250, 200), color="green") + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp1: img_path1 = img_tmp1.name test_img1.save(img_path1) - - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp2: + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp2: img_path2 = img_tmp2.name test_img2.save(img_path2) - + try: spread_page = Page(page_number=1, is_double_spread=True) - + # Calculate positions spread_width_px = 420 * 96 / 25.4 center_px = spread_width_px / 2 - + # First spanning image image1 = ImageData( - image_path=img_path1, - x=center_px - 150, # Centered on split line - y=50, - width=300, - height=150 + image_path=img_path1, x=center_px - 150, y=50, width=300, height=150 # Centered on split line ) - + # Second spanning image (different position) - image2 = ImageData( - image_path=img_path2, - x=center_px - 100, - y=250, - width=250, - height=200 - ) - + image2 = ImageData(image_path=img_path2, x=center_px - 100, y=250, width=250, height=200) + spread_page.layout.add_element(image1) spread_page.layout.add_element(image2) - + project.add_page(spread_page) - - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp: + + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp: pdf_path = pdf_tmp.name - + try: exporter = PDFExporter(project) success, warnings = exporter.export(pdf_path) - + assert success, f"Export failed: {warnings}" assert os.path.exists(pdf_path), "PDF file was not created" - + print(f"✓ Multiple spanning images export successful: {pdf_path}") if warnings: print(f" Warnings: {warnings}") - + finally: if os.path.exists(pdf_path): os.remove(pdf_path) - + finally: if os.path.exists(img_path1): os.remove(img_path1) @@ -566,52 +551,46 @@ def test_pdf_exporter_edge_case_barely_spanning(): """Test image that barely crosses the threshold""" import tempfile from PIL import Image as PILImage - + project = Project("Test Edge Case") project.page_size_mm = (210, 297) project.working_dpi = 96 - - test_img = PILImage.new('RGB', (100, 100), color='yellow') - - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp: + + test_img = PILImage.new("RGB", (100, 100), color="yellow") + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp: img_path = img_tmp.name test_img.save(img_path) - + try: spread_page = Page(page_number=1, is_double_spread=True) - + spread_width_px = 420 * 96 / 25.4 center_px = spread_width_px / 2 - + # Image that just barely crosses the center line - image = ImageData( - image_path=img_path, - x=center_px - 5, # Just 5px overlap - y=100, - width=100, - height=100 - ) - + image = ImageData(image_path=img_path, x=center_px - 5, y=100, width=100, height=100) # Just 5px overlap + spread_page.layout.add_element(image) project.add_page(spread_page) - - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp: + + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp: pdf_path = pdf_tmp.name - + try: exporter = PDFExporter(project) success, warnings = exporter.export(pdf_path) - + assert success, f"Export failed: {warnings}" - + print(f"✓ Edge case (barely spanning) export successful: {pdf_path}") if warnings: print(f" Warnings: {warnings}") - + finally: if os.path.exists(pdf_path): os.remove(pdf_path) - + finally: if os.path.exists(img_path): os.remove(img_path) @@ -622,12 +601,12 @@ def test_pdf_exporter_text_spanning(): project = Project("Test Spanning Text") project.page_size_mm = (210, 297) project.working_dpi = 96 - + spread_page = Page(page_number=1, is_double_spread=True) - + spread_width_px = 420 * 96 / 25.4 center_px = spread_width_px / 2 - + # Text box spanning the center text_box = TextBoxData( text_content="Spanning Text", @@ -636,25 +615,25 @@ def test_pdf_exporter_text_spanning(): x=center_px - 100, y=100, width=200, - height=50 + height=50, ) - + spread_page.layout.add_element(text_box) project.add_page(spread_page) - - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp: + + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp: pdf_path = pdf_tmp.name - + try: exporter = PDFExporter(project) success, warnings = exporter.export(pdf_path) - + assert success, f"Export failed: {warnings}" - + print(f"✓ Spanning text box export successful: {pdf_path}") if warnings: print(f" Warnings: {warnings}") - + finally: if os.path.exists(pdf_path): os.remove(pdf_path) @@ -664,76 +643,72 @@ def test_pdf_exporter_spanning_image_aspect_ratio(): """Test that spanning images maintain correct aspect ratio and can be recombined""" import tempfile from PIL import Image as PILImage, ImageDraw - + project = Project("Test Aspect Ratio") project.page_size_mm = (210, 297) # A4 project.working_dpi = 96 - + # Create a distinctive test image: red left half, blue right half, with a vertical line in center test_width, test_height = 800, 400 - test_img = PILImage.new('RGB', (test_width, test_height)) + test_img = PILImage.new("RGB", (test_width, test_height)) draw = ImageDraw.Draw(test_img) - + # Fill left half red draw.rectangle([0, 0, test_width // 2, test_height], fill=(255, 0, 0)) - + # Fill right half blue draw.rectangle([test_width // 2, 0, test_width, test_height], fill=(0, 0, 255)) - + # Draw a black vertical line in the middle draw.line([test_width // 2, 0, test_width // 2, test_height], fill=(0, 0, 0), width=5) - + # Draw horizontal reference lines for visual verification for y in range(0, test_height, 50): draw.line([0, y, test_width, y], fill=(255, 255, 255), width=2) - + # Save test image to temporary file - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp: + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp: img_path = img_tmp.name test_img.save(img_path) - + try: # Create a double-page spread spread_page = Page(page_number=1, is_double_spread=True) - + # Calculate positions spread_width_px = 420 * 96 / 25.4 # ~1587 pixels center_px = spread_width_px / 2 # ~794 pixels - + # Create an image element that spans the center with a specific aspect ratio # Make it 600px wide and 300px tall (2:1 aspect ratio) image_width_px = 600 image_height_px = 300 image_x_px = center_px - 300 # Centered on the split line - + spanning_image = ImageData( - image_path=img_path, - x=image_x_px, - y=100, - width=image_width_px, - height=image_height_px + image_path=img_path, x=image_x_px, y=100, width=image_width_px, height=image_height_px ) spread_page.layout.add_element(spanning_image) - + project.add_page(spread_page) - + # Export to temporary PDF - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp: + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp: pdf_path = pdf_tmp.name - + try: exporter = PDFExporter(project) success, warnings = exporter.export(pdf_path) - + assert success, f"Export failed: {warnings}" assert os.path.exists(pdf_path), "PDF file was not created" - + # Verify the PDF was created and has expected properties # We can't easily extract and verify pixel-perfect image reconstruction without # additional dependencies, but we can verify the export succeeded file_size = os.path.getsize(pdf_path) assert file_size > 1000, "PDF file seems too small" - + print(f"✓ Spanning image aspect ratio test successful: {pdf_path}") print(f" Original image: {test_width}x{test_height} (aspect {test_width/test_height:.2f}:1)") print(f" Element size: {image_width_px}x{image_height_px} (aspect {image_width_px/image_height_px:.2f}:1)") @@ -741,14 +716,14 @@ def test_pdf_exporter_spanning_image_aspect_ratio(): print(f" Left portion: {center_px - image_x_px:.1f}px wide") print(f" Right portion: {image_width_px - (center_px - image_x_px):.1f}px wide") print(f" PDF size: {file_size} bytes") - + if warnings: print(f" Warnings: {warnings}") - + finally: if os.path.exists(pdf_path): os.remove(pdf_path) - + finally: if os.path.exists(img_path): os.remove(img_path) @@ -758,74 +733,76 @@ def test_pdf_exporter_varying_aspect_ratios(): """Test spanning images with various aspect ratios""" import tempfile from PIL import Image as PILImage, ImageDraw - + project = Project("Test Varying Aspects") project.page_size_mm = (210, 297) project.working_dpi = 96 - + # Test different aspect ratios test_configs = [ - ("Square", 400, 400), # 1:1 - ("Landscape", 800, 400), # 2:1 - ("Portrait", 400, 800), # 1:2 - ("Wide", 1200, 400), # 3:1 + ("Square", 400, 400), # 1:1 + ("Landscape", 800, 400), # 2:1 + ("Portrait", 400, 800), # 1:2 + ("Wide", 1200, 400), # 3:1 ] - + spread_width_px = 420 * 96 / 25.4 center_px = spread_width_px / 2 - + for idx, (name, img_w, img_h) in enumerate(test_configs): # Create test image - test_img = PILImage.new('RGB', (img_w, img_h)) + test_img = PILImage.new("RGB", (img_w, img_h)) draw = ImageDraw.Draw(test_img) - + # Different colors for each test colors = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0)] draw.rectangle([0, 0, img_w // 2, img_h], fill=colors[idx]) - draw.rectangle([img_w // 2, 0, img_w, img_h], fill=(255-colors[idx][0], 255-colors[idx][1], 255-colors[idx][2])) - - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp: + 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: img_path = img_tmp.name test_img.save(img_path) - + try: spread_page = Page(page_number=idx + 1, is_double_spread=True) - + # Position spanning element element_width_px = 500 element_height_px = int(500 * img_h / img_w) # Maintain aspect ratio - + spanning_image = ImageData( image_path=img_path, x=center_px - 250, y=100 + idx * 200, width=element_width_px, - height=element_height_px + height=element_height_px, ) spread_page.layout.add_element(spanning_image) - + project.add_page(spread_page) - + finally: if os.path.exists(img_path): os.remove(img_path) - + # Export all pages - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp: + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp: pdf_path = pdf_tmp.name - + try: exporter = PDFExporter(project) success, warnings = exporter.export(pdf_path) - + assert success, f"Export failed: {warnings}" assert os.path.exists(pdf_path), "PDF file was not created" - + print(f"✓ Varying aspect ratios test successful: {pdf_path}") print(f" Tested {len(test_configs)} different aspect ratios") if warnings: print(f" Warnings: {warnings}") - + finally: if os.path.exists(pdf_path): os.remove(pdf_path) @@ -842,7 +819,7 @@ def test_pdf_exporter_rotated_image(): # Create a distinctive test image that shows rotation clearly # Make it wider than tall (400x200) so we can verify rotation - test_img = PILImage.new('RGB', (400, 200), color='white') + test_img = PILImage.new("RGB", (400, 200), color="white") draw = ImageDraw.Draw(test_img) # Draw a pattern that shows orientation @@ -855,7 +832,7 @@ def test_pdf_exporter_rotated_image(): # Yellow vertical stripe on right draw.rectangle([350, 0, 400, 200], fill=(255, 255, 0)) - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp: + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp: img_path = img_tmp.name test_img.save(img_path) @@ -865,11 +842,7 @@ def test_pdf_exporter_rotated_image(): # Add image with 90-degree PIL rotation image = ImageData( - image_path=img_path, - x=50, - y=50, - width=200, # These dimensions are for the rotated version - height=400 + image_path=img_path, x=50, y=50, width=200, height=400 # These dimensions are for the rotated version ) image.pil_rotation_90 = 1 # 90 degree rotation image.image_dimensions = (400, 200) # Original dimensions before rotation @@ -878,7 +851,7 @@ def test_pdf_exporter_rotated_image(): project.add_page(page) # Export to PDF - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp: + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp: pdf_path = pdf_tmp.name try: @@ -908,84 +881,85 @@ def test_pdf_exporter_image_downsampling(): """Test that export DPI controls image downsampling and reduces file size""" import tempfile from PIL import Image as PILImage - + project = Project("Test Downsampling") project.page_size_mm = (210, 297) # A4 project.working_dpi = 96 - + # Create a large test image (4000x3000 - typical high-res camera) - large_img = PILImage.new('RGB', (4000, 3000)) + large_img = PILImage.new("RGB", (4000, 3000)) # Add some pattern so it doesn't compress too much import random + pixels = large_img.load() for i in range(0, 4000, 10): for j in range(0, 3000, 10): pixels[i, j] = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) - - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp: + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp: img_path = img_tmp.name large_img.save(img_path) - + try: # Create a page with the large image page = Page(page_number=1, is_double_spread=False) - + # Add image at reasonable size (100mm x 75mm) image = ImageData( image_path=img_path, x=50, y=50, width=int(100 * 96 / 25.4), # ~378 px - height=int(75 * 96 / 25.4) # ~283 px + height=int(75 * 96 / 25.4), # ~283 px ) page.layout.add_element(image) project.add_page(page) - + # Export with high DPI (300 - print quality) - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp1: + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp1: pdf_path_300dpi = pdf_tmp1.name - + # Export with low DPI (150 - screen quality) - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp2: + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp2: pdf_path_150dpi = pdf_tmp2.name - + try: # Export at 300 DPI exporter_300 = PDFExporter(project, export_dpi=300) success1, warnings1 = exporter_300.export(pdf_path_300dpi) assert success1, f"300 DPI export failed: {warnings1}" - + # Export at 150 DPI exporter_150 = PDFExporter(project, export_dpi=150) success2, warnings2 = exporter_150.export(pdf_path_150dpi) assert success2, f"150 DPI export failed: {warnings2}" - + # Check file sizes size_300dpi = os.path.getsize(pdf_path_300dpi) size_150dpi = os.path.getsize(pdf_path_150dpi) - + print(f"✓ Image downsampling test successful:") print(f" Original image: 4000x3000 pixels") print(f" Element size: 100mm x 75mm") print(f" PDF at 300 DPI: {size_300dpi:,} bytes") print(f" PDF at 150 DPI: {size_150dpi:,} bytes") print(f" Size reduction: {(1 - size_150dpi/size_300dpi)*100:.1f}%") - + # 150 DPI should be smaller than 300 DPI - assert size_150dpi < size_300dpi, \ - f"150 DPI file ({size_150dpi}) should be smaller than 300 DPI file ({size_300dpi})" - + assert ( + size_150dpi < size_300dpi + ), f"150 DPI file ({size_150dpi}) should be smaller than 300 DPI file ({size_300dpi})" + # 150 DPI should be significantly smaller (at least 50% reduction) reduction_ratio = size_150dpi / size_300dpi - assert reduction_ratio < 0.7, \ - f"150 DPI should be at least 30% smaller, got {(1-reduction_ratio)*100:.1f}%" - + assert reduction_ratio < 0.7, f"150 DPI should be at least 30% smaller, got {(1-reduction_ratio)*100:.1f}%" + finally: if os.path.exists(pdf_path_300dpi): os.remove(pdf_path_300dpi) if os.path.exists(pdf_path_150dpi): os.remove(pdf_path_150dpi) - + finally: if os.path.exists(img_path): os.remove(img_path) diff --git a/tests/test_project.py b/tests/test_project.py index a3ad56f..f51d295 100755 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -15,7 +15,7 @@ class TestPage: """Test Page initialization with default values""" layout = PageLayout() page = Page(layout=layout, page_number=1) - + assert page.layout is layout assert page.page_number == 1 @@ -23,7 +23,7 @@ class TestPage: """Test Page initialization with custom parameters""" layout = PageLayout() page = Page(layout=layout, page_number=5) - + assert page.layout is layout assert page.page_number == 5 @@ -32,7 +32,7 @@ class TestPage: layout = PageLayout() page = Page(layout=layout, page_number=1) page.page_number = 10 - + assert page.page_number == 10 @@ -42,7 +42,7 @@ class TestProject: def test_initialization_default(self): """Test Project initialization with default values""" project = Project() - + assert project.name == "Untitled Project" assert len(project.pages) == 0 assert project.working_dpi == 300 @@ -51,7 +51,7 @@ class TestProject: def test_initialization_with_name(self): """Test Project initialization with custom name""" project = Project(name="My Album") - + assert project.name == "My Album" def test_add_page(self): @@ -59,24 +59,24 @@ class TestProject: project = Project() layout = PageLayout() page = Page(layout=layout, page_number=1) - + project.add_page(page) - + assert len(project.pages) == 1 assert project.pages[0] is page def test_add_multiple_pages(self): """Test adding multiple pages to the project""" project = Project() - + page1 = Page(layout=PageLayout(), page_number=1) page2 = Page(layout=PageLayout(), page_number=2) page3 = Page(layout=PageLayout(), page_number=3) - + project.add_page(page1) project.add_page(page2) project.add_page(page3) - + assert len(project.pages) == 3 assert project.pages[0] is page1 assert project.pages[1] is page2 @@ -85,15 +85,15 @@ class TestProject: def test_remove_page(self): """Test removing a page from the project""" project = Project() - + page1 = Page(layout=PageLayout(), page_number=1) page2 = Page(layout=PageLayout(), page_number=2) - + project.add_page(page1) project.add_page(page2) - + project.remove_page(page1) - + assert len(project.pages) == 1 assert project.pages[0] is page2 @@ -103,7 +103,7 @@ class TestProject: page1 = Page(layout=PageLayout(), page_number=1) page2 = Page(layout=PageLayout(), page_number=2) project.add_page(page1) - + # Try to remove a page that was never added with pytest.raises(ValueError): project.remove_page(page2) @@ -112,51 +112,51 @@ class TestProject: """Test modifying working DPI""" project = Project() project.working_dpi = 300 - + assert project.working_dpi == 300 def test_page_size_modification(self): """Test modifying page size""" project = Project() project.page_size_mm = (300, 400) - + assert project.page_size_mm == (300, 400) def test_project_name_modification(self): """Test modifying project name""" project = Project(name="Initial Name") project.name = "New Name" - + assert project.name == "New Name" def test_asset_manager_exists(self): """Test that project has an asset manager""" project = Project() - - assert hasattr(project, 'asset_manager') + + assert hasattr(project, "asset_manager") assert project.asset_manager is not None def test_history_exists(self): """Test that project has a history manager""" project = Project() - - assert hasattr(project, 'history') + + assert hasattr(project, "history") assert project.history is not None def test_pages_list_is_mutable(self): """Test that pages list can be directly modified""" project = Project() page = Page(layout=PageLayout(), page_number=1) - + project.pages.append(page) - + assert len(project.pages) == 1 assert project.pages[0] is page def test_empty_project_has_no_pages(self): """Test that a new project has no pages""" project = Project() - + assert len(project.pages) == 0 assert project.pages == [] @@ -167,23 +167,17 @@ class TestProjectWithPages: def test_project_with_populated_pages(self, sample_image_data): """Test project with pages containing elements""" project = Project(name="Photo Album") - + # Create pages with elements for i in range(3): layout = PageLayout() - img = ImageData( - image_path=f"image_{i}.jpg", - x=10 + i*10, - y=20 + i*10, - width=100, - height=100 - ) + img = ImageData(image_path=f"image_{i}.jpg", x=10 + i * 10, y=20 + i * 10, width=100, height=100) layout.add_element(img) - page = Page(layout=layout, page_number=i+1) + page = Page(layout=layout, page_number=i + 1) project.add_page(page) - + assert len(project.pages) == 3 - + # Check each page has elements for i, page in enumerate(project.pages): assert len(page.layout.elements) == 1 @@ -192,18 +186,18 @@ class TestProjectWithPages: def test_reorder_pages(self): """Test reordering pages in project""" project = Project() - + page1 = Page(layout=PageLayout(), page_number=1) page2 = Page(layout=PageLayout(), page_number=2) page3 = Page(layout=PageLayout(), page_number=3) - + project.add_page(page1) project.add_page(page2) project.add_page(page3) - + # Swap page 1 and page 3 project.pages[0], project.pages[2] = project.pages[2], project.pages[0] - + assert project.pages[0] is page3 assert project.pages[1] is page2 assert project.pages[2] is page1 @@ -211,43 +205,43 @@ class TestProjectWithPages: def test_clear_all_pages(self): """Test clearing all pages from project""" project = Project() - + for i in range(5): - page = Page(layout=PageLayout(), page_number=i+1) + page = Page(layout=PageLayout(), page_number=i + 1) project.add_page(page) - + # Clear all pages project.pages.clear() - + assert len(project.pages) == 0 def test_get_page_by_index(self): """Test accessing pages by index""" project = Project() - + page1 = Page(layout=PageLayout(), page_number=1) page2 = Page(layout=PageLayout(), page_number=2) - + project.add_page(page1) project.add_page(page2) - + assert project.pages[0] is page1 assert project.pages[1] is page2 def test_insert_page_at_position(self): """Test inserting a page at a specific position""" project = Project() - + page1 = Page(layout=PageLayout(), page_number=1) page2 = Page(layout=PageLayout(), page_number=2) page_new = Page(layout=PageLayout(), page_number=99) - + project.add_page(page1) project.add_page(page2) - + # Insert new page in the middle project.pages.insert(1, page_new) - + assert len(project.pages) == 3 assert project.pages[0] is page1 assert project.pages[1] is page_new diff --git a/tests/test_project_serialization.py b/tests/test_project_serialization.py index 830982c..f31c6f6 100755 --- a/tests/test_project_serialization.py +++ b/tests/test_project_serialization.py @@ -39,9 +39,9 @@ def sample_project(temp_dir): def sample_image(temp_dir): """Create a sample image file for testing""" from PIL import Image - + # Create a simple test image - img = Image.new('RGB', (100, 100), color='red') + img = Image.new("RGB", (100, 100), color="red") image_path = os.path.join(temp_dir, "test_image.jpg") img.save(image_path) return image_path @@ -49,42 +49,41 @@ def sample_image(temp_dir): class TestBasicSerialization: """Tests for basic save/load functionality""" - + def test_save_empty_project(self, sample_project, temp_dir): """Test saving an empty project to ZIP""" zip_path = os.path.join(temp_dir, "empty_project.ppz") - + success, error = save_to_zip(sample_project, zip_path) - + assert success is True assert error is None assert os.path.exists(zip_path) - assert zip_path.endswith('.ppz') - + assert zip_path.endswith(".ppz") + def test_save_adds_ppz_extension(self, sample_project, temp_dir): """Test that .ppz extension is added automatically""" zip_path = os.path.join(temp_dir, "project") - + success, error = save_to_zip(sample_project, zip_path) - + assert success is True - expected_path = zip_path + '.ppz' + expected_path = zip_path + ".ppz" assert os.path.exists(expected_path) - + def test_load_empty_project(self, sample_project, temp_dir): """Test loading an empty project from ZIP""" zip_path = os.path.join(temp_dir, "empty_project.ppz") save_to_zip(sample_project, zip_path) - + loaded_project = load_from_zip(zip_path) - assert loaded_project is not None assert loaded_project.name == "Test Project" assert loaded_project.page_size_mm == (210, 297) assert loaded_project.working_dpi == 300 assert len(loaded_project.pages) == 0 - + def test_load_nonexistent_file(self, temp_dir): """Test loading from a non-existent file""" zip_path = os.path.join(temp_dir, "nonexistent.ppz") @@ -95,34 +94,34 @@ class TestBasicSerialization: except Exception as error: assert error is not None assert "not found" in str(error).lower() - + def test_save_project_with_pages(self, sample_project, temp_dir): """Test saving a project with multiple pages""" # Add pages for i in range(3): layout = PageLayout() - page = Page(layout=layout, page_number=i+1) + page = Page(layout=layout, page_number=i + 1) sample_project.add_page(page) - + zip_path = os.path.join(temp_dir, "project_with_pages.ppz") success, error = save_to_zip(sample_project, zip_path) - + assert success is True assert os.path.exists(zip_path) - + def test_load_project_with_pages(self, sample_project, temp_dir): """Test loading a project with multiple pages""" # Add pages for i in range(3): layout = PageLayout() - page = Page(layout=layout, page_number=i+1) + page = Page(layout=layout, page_number=i + 1) sample_project.add_page(page) - + # Save and load zip_path = os.path.join(temp_dir, "project_with_pages.ppz") save_to_zip(sample_project, zip_path) loaded_project = load_from_zip(zip_path) - + assert loaded_project is not None assert len(loaded_project.pages) == 3 assert loaded_project.pages[0].page_number == 1 @@ -131,139 +130,139 @@ class TestBasicSerialization: class TestZipStructure: """Tests for ZIP file structure and content""" - + def test_zip_contains_project_json(self, sample_project, temp_dir): """Test that ZIP contains project.json""" zip_path = os.path.join(temp_dir, "test.ppz") save_to_zip(sample_project, zip_path) - - with zipfile.ZipFile(zip_path, 'r') as zipf: - assert 'project.json' in zipf.namelist() - + + with zipfile.ZipFile(zip_path, "r") as zipf: + assert "project.json" in zipf.namelist() + def test_project_json_is_valid(self, sample_project, temp_dir): """Test that project.json contains valid JSON""" zip_path = os.path.join(temp_dir, "test.ppz") save_to_zip(sample_project, zip_path) - - with zipfile.ZipFile(zip_path, 'r') as zipf: - project_json = zipf.read('project.json').decode('utf-8') + + with zipfile.ZipFile(zip_path, "r") as zipf: + project_json = zipf.read("project.json").decode("utf-8") data = json.loads(project_json) - - assert 'name' in data - assert 'serialization_version' in data - assert data['name'] == "Test Project" - + + assert "name" in data + assert "serialization_version" in data + assert data["name"] == "Test Project" + def test_version_in_serialized_data(self, sample_project, temp_dir): """Test that version information is included""" zip_path = os.path.join(temp_dir, "test.ppz") save_to_zip(sample_project, zip_path) - - with zipfile.ZipFile(zip_path, 'r') as zipf: - project_json = zipf.read('project.json').decode('utf-8') + + with zipfile.ZipFile(zip_path, "r") as zipf: + project_json = zipf.read("project.json").decode("utf-8") data = json.loads(project_json) - - assert 'serialization_version' in data - assert data['serialization_version'] == "3.0" + + assert "serialization_version" in data + assert data["serialization_version"] == "3.0" class TestAssetManagement: """Tests for asset bundling and management""" - + def test_save_project_with_image(self, sample_project, sample_image, temp_dir): """Test saving a project with an image""" # Import image to project imported_path = sample_project.asset_manager.import_asset(sample_image) - + # Create page with image layout = PageLayout() img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100) layout.add_element(img_data) page = Page(layout=layout, page_number=1) sample_project.add_page(page) - + # Save zip_path = os.path.join(temp_dir, "project_with_image.ppz") success, error = save_to_zip(sample_project, zip_path) - + assert success is True assert os.path.exists(zip_path) - + def test_assets_folder_in_zip(self, sample_project, sample_image, temp_dir): """Test that assets folder is included in ZIP""" # Import image imported_path = sample_project.asset_manager.import_asset(sample_image) - + # Create page with image layout = PageLayout() img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100) layout.add_element(img_data) page = Page(layout=layout, page_number=1) sample_project.add_page(page) - + # Save zip_path = os.path.join(temp_dir, "project_with_image.ppz") save_to_zip(sample_project, zip_path) - + # Check ZIP contents - with zipfile.ZipFile(zip_path, 'r') as zipf: + with zipfile.ZipFile(zip_path, "r") as zipf: names = zipf.namelist() # Should contain assets folder - asset_files = [n for n in names if n.startswith('assets/')] + asset_files = [n for n in names if n.startswith("assets/")] assert len(asset_files) > 0 - + def test_load_project_with_image(self, sample_project, sample_image, temp_dir): """Test loading a project with images""" # Import image imported_path = sample_project.asset_manager.import_asset(sample_image) - + # Create page with image layout = PageLayout() img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100) layout.add_element(img_data) page = Page(layout=layout, page_number=1) sample_project.add_page(page) - + # Save and load zip_path = os.path.join(temp_dir, "project_with_image.ppz") save_to_zip(sample_project, zip_path) loaded_project = load_from_zip(zip_path) - + assert loaded_project is not None assert len(loaded_project.pages) == 1 assert len(loaded_project.pages[0].layout.elements) == 1 - + # Verify image element img_element = loaded_project.pages[0].layout.elements[0] assert isinstance(img_element, ImageData) assert img_element.image_path != "" - + def test_asset_reference_counts_preserved(self, sample_project, sample_image, temp_dir): """Test that asset reference counts are preserved""" # Import image imported_path = sample_project.asset_manager.import_asset(sample_image) - + # Use image twice layout1 = PageLayout() img1 = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100) layout1.add_element(img1) page1 = Page(layout=layout1, page_number=1) sample_project.add_page(page1) - + layout2 = PageLayout() img2 = ImageData(image_path=imported_path, x=20, y=20, width=100, height=100) layout2.add_element(img2) page2 = Page(layout=layout2, page_number=2) sample_project.add_page(page2) - + # Get relative path for reference count check rel_path = os.path.relpath(imported_path, sample_project.folder_path) original_ref_count = sample_project.asset_manager.get_reference_count(rel_path) - + # Save and load zip_path = os.path.join(temp_dir, "project_refs.ppz") save_to_zip(sample_project, zip_path) loaded_project = load_from_zip(zip_path) - + assert loaded_project is not None # Reference counts should be preserved # Note: The actual reference counting behavior depends on deserialize implementation @@ -271,7 +270,7 @@ class TestAssetManagement: class TestPortability: """Tests for project portability across different locations""" - + def test_load_to_different_directory(self, sample_project, sample_image, temp_dir): """Test loading project to a different directory""" # Import image and create page @@ -281,23 +280,23 @@ class TestPortability: layout.add_element(img_data) page = Page(layout=layout, page_number=1) sample_project.add_page(page) - + # Save zip_path = os.path.join(temp_dir, "portable_project.ppz") save_to_zip(sample_project, zip_path) - + # Load to a different location new_location = os.path.join(temp_dir, "different_location") loaded_project = load_from_zip(zip_path, extract_to=new_location) - + assert loaded_project is not None assert loaded_project.folder_path == new_location assert os.path.exists(new_location) - + # Verify assets were extracted assets_folder = os.path.join(new_location, "assets") assert os.path.exists(assets_folder) - + def test_relative_paths_work_after_move(self, sample_project, sample_image, temp_dir): """Test that relative paths still work after loading to different location""" # Import image @@ -307,19 +306,19 @@ class TestPortability: layout.add_element(img_data) page = Page(layout=layout, page_number=1) sample_project.add_page(page) - + # Save zip_path = os.path.join(temp_dir, "portable_project.ppz") save_to_zip(sample_project, zip_path) - + # Load to different location new_location = os.path.join(temp_dir, "new_location") loaded_project = load_from_zip(zip_path, extract_to=new_location) - + # Verify image path is accessible from new location img_element = loaded_project.pages[0].layout.elements[0] image_path = img_element.image_path - + # Image path should exist # Note: May be absolute or relative depending on implementation if not os.path.isabs(image_path): @@ -331,77 +330,73 @@ class TestPortability: class TestProjectInfo: """Tests for get_project_info utility function""" - + def test_get_project_info(self, sample_project, temp_dir): """Test getting project info without loading""" # Add some pages for i in range(5): layout = PageLayout() - page = Page(layout=layout, page_number=i+1) + page = Page(layout=layout, page_number=i + 1) sample_project.add_page(page) - + # Save zip_path = os.path.join(temp_dir, "info_test.ppz") save_to_zip(sample_project, zip_path) - + # Get info info = get_project_info(zip_path) - + assert info is not None - assert info['name'] == "Test Project" - assert info['page_count'] == 5 - assert info['version'] == "3.0" - assert info['working_dpi'] == 300 - + assert info["name"] == "Test Project" + assert info["page_count"] == 5 + assert info["version"] == "3.0" + assert info["working_dpi"] == 300 + def test_get_info_invalid_zip(self, temp_dir): """Test getting info from invalid ZIP""" zip_path = os.path.join(temp_dir, "invalid.ppz") - + info = get_project_info(zip_path) - + assert info is None class TestEdgeCases: """Tests for edge cases and error handling""" - + def test_save_to_invalid_path(self, sample_project): """Test saving to an invalid path""" invalid_path = "/nonexistent/directory/project.ppz" - + success, error = save_to_zip(sample_project, invalid_path) - + assert success is False assert error is not None - + def test_load_corrupted_zip(self, temp_dir): """Test loading a corrupted ZIP file""" # Create a fake corrupted file corrupted_path = os.path.join(temp_dir, "corrupted.ppz") - with open(corrupted_path, 'w') as f: + with open(corrupted_path, "w") as f: f.write("This is not a ZIP file") - + try: - loaded_project = load_from_zip(corrupted_path) - assert False, "Should have raised an exception" - except Exception as error: - assert error is not None - + def test_load_zip_without_project_json(self, temp_dir): """Test loading a ZIP without project.json""" zip_path = os.path.join(temp_dir, "no_json.ppz") # Create ZIP without project.json - with zipfile.ZipFile(zip_path, 'w') as zipf: - zipf.writestr('dummy.txt', 'dummy content') + with zipfile.ZipFile(zip_path, "w") as zipf: + zipf.writestr("dummy.txt", "dummy content") try: loaded_project = load_from_zip(zip_path) @@ -409,27 +404,24 @@ class TestEdgeCases: except Exception as error: assert error is not None assert "project.json not found" in str(error) - + def test_project_with_text_elements(self, sample_project, temp_dir): """Test saving/loading project with text elements""" # Create page with text layout = PageLayout() - text = TextBoxData( - text_content="Hello World", - x=10, y=10, width=200, height=50 - ) + text = TextBoxData(text_content="Hello World", x=10, y=10, width=200, height=50) layout.add_element(text) page = Page(layout=layout, page_number=1) sample_project.add_page(page) - + # Save and load zip_path = os.path.join(temp_dir, "with_text.ppz") save_to_zip(sample_project, zip_path) loaded_project = load_from_zip(zip_path) - + assert loaded_project is not None assert len(loaded_project.pages) == 1 - + text_element = loaded_project.pages[0].layout.elements[0] assert isinstance(text_element, TextBoxData) assert text_element.text_content == "Hello World" diff --git a/tests/test_project_serializer_full.py b/tests/test_project_serializer_full.py new file mode 100644 index 0000000..2f3bdec --- /dev/null +++ b/tests/test_project_serializer_full.py @@ -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 diff --git a/tests/test_ribbon_builder.py b/tests/test_ribbon_builder.py new file mode 100644 index 0000000..a945371 --- /dev/null +++ b/tests/test_ribbon_builder.py @@ -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 diff --git a/tests/test_ribbon_widget.py b/tests/test_ribbon_widget.py new file mode 100644 index 0000000..cc53609 --- /dev/null +++ b/tests/test_ribbon_widget.py @@ -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 diff --git a/tests/test_rotation_serialization.py b/tests/test_rotation_serialization.py index 83fd042..7088695 100755 --- a/tests/test_rotation_serialization.py +++ b/tests/test_rotation_serialization.py @@ -19,7 +19,7 @@ class TestRotationSerialization: def sample_image(self): """Create a sample test image""" # Create a 400x200 test image (wider than tall) - img = Image.new('RGBA', (400, 200), color=(255, 0, 0, 255)) + img = Image.new("RGBA", (400, 200), color=(255, 0, 0, 255)) return img def test_serialize_rotation_metadata(self): @@ -47,7 +47,7 @@ class TestRotationSerialization: "image_path": "test.jpg", "crop_info": (0, 0, 1, 1), "pil_rotation_90": 1, - "image_dimensions": (400, 200) + "image_dimensions": (400, 200), } img_data = ImageData() @@ -71,8 +71,10 @@ class TestRotationSerialization: img_data._on_async_image_loaded(sample_image) # Verify dimensions are updated to rotated dimensions - assert img_data.image_dimensions == (200, 400), \ - f"Expected rotated dimensions (200, 400), got {img_data.image_dimensions}" + assert img_data.image_dimensions == ( + 200, + 400, + ), f"Expected rotated dimensions (200, 400), got {img_data.image_dimensions}" assert img_data._img_width == 200 assert img_data._img_height == 400 @@ -143,7 +145,7 @@ class TestRotationSerialization: "image_path": "test.jpg", "crop_info": (0, 0, 1, 1), "pil_rotation_90": 0, # Not set in old format - "image_dimensions": (400, 200) + "image_dimensions": (400, 200), } img_data = ImageData() @@ -182,7 +184,6 @@ class TestRotationSerialization: img2._on_async_image_loaded(sample_image) # Verify dimensions are STILL correct after reload - assert img2.image_dimensions == (200, 400), \ - "Dimensions should remain correct after reload" + assert img2.image_dimensions == (200, 400), "Dimensions should remain correct after reload" assert img2._img_width == 200 assert img2._img_height == 400 diff --git a/tests/test_size_ops_mixin.py b/tests/test_size_ops_mixin.py index 4913df8..58b25ae 100755 --- a/tests/test_size_ops_mixin.py +++ b/tests/test_size_ops_mixin.py @@ -30,7 +30,7 @@ class TestSizeWindow(SizeOperationsMixin, QMainWindow): return len(self.gl_widget.selected_elements) >= min_count def get_current_page(self): - if hasattr(self, '_current_page'): + if hasattr(self, "_current_page"): return self._current_page return None @@ -47,7 +47,7 @@ class TestSizeWindow(SizeOperationsMixin, QMainWindow): class TestMakeSameSize: """Test make_same_size method""" - @patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager") def test_make_same_size_success(self, mock_manager, qtbot): window = TestSizeWindow() qtbot.addWidget(window) @@ -57,10 +57,7 @@ class TestMakeSameSize: window.gl_widget.selected_elements = {element1, element2} - mock_manager.make_same_size.return_value = [ - (element1, (0, 0), (100, 100)), - (element2, (150, 0), (200, 150)) - ] + mock_manager.make_same_size.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))] window.make_same_size() @@ -84,7 +81,7 @@ class TestMakeSameSize: class TestMakeSameWidth: """Test make_same_width method""" - @patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager") def test_make_same_width_success(self, mock_manager, qtbot): window = TestSizeWindow() qtbot.addWidget(window) @@ -94,10 +91,7 @@ class TestMakeSameWidth: window.gl_widget.selected_elements = {element1, element2} - mock_manager.make_same_width.return_value = [ - (element1, (0, 0), (100, 100)), - (element2, (150, 0), (200, 150)) - ] + mock_manager.make_same_width.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))] window.make_same_width() @@ -109,7 +103,7 @@ class TestMakeSameWidth: class TestMakeSameHeight: """Test make_same_height method""" - @patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager") def test_make_same_height_success(self, mock_manager, qtbot): window = TestSizeWindow() qtbot.addWidget(window) @@ -119,10 +113,7 @@ class TestMakeSameHeight: window.gl_widget.selected_elements = {element1, element2} - mock_manager.make_same_height.return_value = [ - (element1, (0, 0), (100, 100)), - (element2, (150, 0), (200, 150)) - ] + mock_manager.make_same_height.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))] window.make_same_height() @@ -134,7 +125,7 @@ class TestMakeSameHeight: class TestFitToWidth: """Test fit_to_width method""" - @patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager") def test_fit_to_width_success(self, mock_manager, qtbot): window = TestSizeWindow() qtbot.addWidget(window) @@ -174,7 +165,7 @@ class TestFitToWidth: class TestFitToHeight: """Test fit_to_height method""" - @patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager") def test_fit_to_height_success(self, mock_manager, qtbot): window = TestSizeWindow() qtbot.addWidget(window) @@ -199,7 +190,7 @@ class TestFitToHeight: class TestFitToPage: """Test fit_to_page method""" - @patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager") def test_fit_to_page_success(self, mock_manager, qtbot): window = TestSizeWindow() qtbot.addWidget(window) @@ -238,7 +229,7 @@ class TestFitToPage: class TestSizeCommandPattern: """Test size operations with command pattern""" - @patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager") def test_size_operation_creates_command(self, mock_manager, qtbot): window = TestSizeWindow() qtbot.addWidget(window) @@ -248,10 +239,7 @@ class TestSizeCommandPattern: window.gl_widget.selected_elements = {element1, element2} - mock_manager.make_same_size.return_value = [ - (element1, (0, 0), (100, 100)), - (element2, (150, 0), (200, 150)) - ] + mock_manager.make_same_size.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))] assert not window.project.history.can_undo() @@ -259,7 +247,7 @@ class TestSizeCommandPattern: assert window.project.history.can_undo() - @patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager") def test_fit_operation_creates_command(self, mock_manager, qtbot): window = TestSizeWindow() qtbot.addWidget(window) @@ -284,7 +272,7 @@ class TestSizeCommandPattern: class TestExpandImage: """Test expand_image method""" - @patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') + @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager") def test_expand_image_success(self, mock_manager, qtbot): window = TestSizeWindow() qtbot.addWidget(window) diff --git a/tests/test_snapping.py b/tests/test_snapping.py index 9f26e0e..bd02279 100755 --- a/tests/test_snapping.py +++ b/tests/test_snapping.py @@ -11,25 +11,25 @@ class TestGuide: def test_guide_initialization(self): """Test Guide initialization""" - guide = Guide(position=50.0, orientation='vertical') + guide = Guide(position=50.0, orientation="vertical") assert guide.position == 50.0 - assert guide.orientation == 'vertical' + assert guide.orientation == "vertical" def test_guide_serialization(self): """Test Guide serialization to dictionary""" - guide = Guide(position=75.5, orientation='horizontal') + guide = Guide(position=75.5, orientation="horizontal") data = guide.serialize() - assert data['position'] == 75.5 - assert data['orientation'] == 'horizontal' + assert data["position"] == 75.5 + assert data["orientation"] == "horizontal" def test_guide_deserialization(self): """Test Guide deserialization from dictionary""" - data = {'position': 100.0, 'orientation': 'vertical'} + data = {"position": 100.0, "orientation": "vertical"} guide = Guide.deserialize(data) assert guide.position == 100.0 - assert guide.orientation == 'vertical' + assert guide.orientation == "vertical" def test_guide_deserialization_with_defaults(self): """Test Guide deserialization with missing fields uses defaults""" @@ -37,7 +37,7 @@ class TestGuide: guide = Guide.deserialize(data) assert guide.position == 0 - assert guide.orientation == 'vertical' + assert guide.orientation == "vertical" class TestSnappingSystem: @@ -46,7 +46,7 @@ class TestSnappingSystem: def test_initialization_default(self): """Test SnappingSystem initialization with default values""" system = SnappingSystem() - + assert system.snap_threshold_mm == 5.0 assert system.grid_size_mm == 10.0 assert system.snap_to_grid == False @@ -62,19 +62,19 @@ class TestSnappingSystem: def test_add_guide(self): """Test adding a guide""" system = SnappingSystem() - guide = system.add_guide(position=50.0, orientation='vertical') + guide = system.add_guide(position=50.0, orientation="vertical") assert len(system.guides) == 1 assert guide.position == 50.0 - assert guide.orientation == 'vertical' + assert guide.orientation == "vertical" assert guide in system.guides def test_add_multiple_guides(self): """Test adding multiple guides""" system = SnappingSystem() - guide1 = system.add_guide(position=50.0, orientation='vertical') - guide2 = system.add_guide(position=100.0, orientation='horizontal') - guide3 = system.add_guide(position=150.0, orientation='vertical') + guide1 = system.add_guide(position=50.0, orientation="vertical") + guide2 = system.add_guide(position=100.0, orientation="horizontal") + guide3 = system.add_guide(position=150.0, orientation="vertical") assert len(system.guides) == 3 assert guide1 in system.guides @@ -84,8 +84,8 @@ class TestSnappingSystem: def test_remove_guide(self): """Test removing a guide""" system = SnappingSystem() - guide = system.add_guide(position=50.0, orientation='vertical') - + guide = system.add_guide(position=50.0, orientation="vertical") + system.remove_guide(guide) assert len(system.guides) == 0 assert guide not in system.guides @@ -93,9 +93,9 @@ class TestSnappingSystem: def test_remove_guide_not_in_list(self): """Test removing a guide that's not in the list does nothing""" system = SnappingSystem() - guide1 = system.add_guide(position=50.0, orientation='vertical') - guide2 = Guide(position=100.0, orientation='horizontal') - + guide1 = system.add_guide(position=50.0, orientation="vertical") + guide2 = Guide(position=100.0, orientation="horizontal") + # Should not raise an error system.remove_guide(guide2) assert len(system.guides) == 1 @@ -104,9 +104,9 @@ class TestSnappingSystem: def test_clear_guides(self): """Test clearing all guides""" system = SnappingSystem() - system.add_guide(position=50.0, orientation='vertical') - system.add_guide(position=100.0, orientation='horizontal') - system.add_guide(position=150.0, orientation='vertical') + system.add_guide(position=50.0, orientation="vertical") + system.add_guide(position=100.0, orientation="horizontal") + system.add_guide(position=150.0, orientation="vertical") system.clear_guides() assert len(system.guides) == 0 @@ -151,13 +151,13 @@ class TestSnappingSystem: # Position near a grid line dpi = 300 grid_size_px = 10.0 * dpi / 25.4 # ~118 pixels - + position = (grid_size_px + 5, grid_size_px + 5) # Close to a grid point size = (100.0, 100.0) page_size = (210.0, 297.0) snapped = system.snap_position(position, size, page_size, dpi=dpi) - + # Should snap to nearest grid line assert abs(snapped[0] - grid_size_px) < 1 # Allow small floating point error assert abs(snapped[1] - grid_size_px) < 1 @@ -173,8 +173,8 @@ class TestSnappingSystem: guide_pos_mm = 50.0 guide_pos_px = guide_pos_mm * dpi / 25.4 - system.add_guide(position=guide_pos_mm, orientation='vertical') - system.add_guide(position=guide_pos_mm, orientation='horizontal') + system.add_guide(position=guide_pos_mm, orientation="vertical") + system.add_guide(position=guide_pos_mm, orientation="horizontal") # Position near the guides position = (guide_pos_px + 5, guide_pos_px + 5) @@ -182,7 +182,7 @@ class TestSnappingSystem: page_size = (210.0, 297.0) snapped = system.snap_position(position, size, page_size, dpi=dpi) - + # Should snap to guides assert abs(snapped[0] - guide_pos_px) < 1 assert abs(snapped[1] - guide_pos_px) < 1 @@ -203,6 +203,7 @@ class TestSnappingSystem: def test_snap_resize_bottom_right_handle(self): """Test snap_resize with bottom-right handle""" from pyPhotoAlbum.snapping import SnapResizeParams + system = SnappingSystem(snap_threshold_mm=5.0) system.snap_to_grid = True system.grid_size_mm = 10.0 @@ -211,17 +212,11 @@ class TestSnappingSystem: size = (200.0, 200.0) dx = 10.0 dy = 10.0 - resize_handle = 'se' + resize_handle = "se" page_size = (210.0, 297.0) params = SnapResizeParams( - position=position, - size=size, - dx=dx, - dy=dy, - resize_handle=resize_handle, - page_size=page_size, - dpi=300 + position=position, size=size, dx=dx, dy=dy, resize_handle=resize_handle, page_size=page_size, dpi=300 ) new_pos, new_size = system.snap_resize(params) @@ -234,6 +229,7 @@ class TestSnappingSystem: def test_snap_resize_top_left_handle(self): """Test snap_resize with top-left handle""" from pyPhotoAlbum.snapping import SnapResizeParams + system = SnappingSystem(snap_threshold_mm=5.0) system.snap_to_edges = True @@ -241,11 +237,12 @@ class TestSnappingSystem: size = (200.0, 200.0) dx = -10.0 dy = -10.0 - resize_handle = 'nw' + resize_handle = "nw" page_size = (210.0, 297.0) - params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy, - resize_handle=resize_handle, page_size=page_size, dpi=300) + params = SnapResizeParams( + position=position, size=size, dx=dx, dy=dy, resize_handle=resize_handle, page_size=page_size, dpi=300 + ) new_pos, new_size = system.snap_resize(params) # Both position and size should change for top-left handle @@ -255,6 +252,7 @@ class TestSnappingSystem: def test_snap_resize_top_handle(self): """Test snap_resize with top handle only""" from pyPhotoAlbum.snapping import SnapResizeParams + system = SnappingSystem(snap_threshold_mm=5.0) system.snap_to_edges = True @@ -262,11 +260,12 @@ class TestSnappingSystem: size = (200.0, 200.0) dx = 0.0 dy = -10.0 - resize_handle = 'n' + resize_handle = "n" page_size = (210.0, 297.0) - params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy, - resize_handle=resize_handle, page_size=page_size, dpi=300) + params = SnapResizeParams( + position=position, size=size, dx=dx, dy=dy, resize_handle=resize_handle, page_size=page_size, dpi=300 + ) new_pos, new_size = system.snap_resize(params) # X position should stay same, Y should change @@ -279,6 +278,7 @@ class TestSnappingSystem: def test_snap_resize_right_handle(self): """Test snap_resize with right handle only""" from pyPhotoAlbum.snapping import SnapResizeParams + system = SnappingSystem(snap_threshold_mm=5.0) system.snap_to_edges = True @@ -286,11 +286,12 @@ class TestSnappingSystem: size = (200.0, 200.0) dx = 10.0 dy = 0.0 - resize_handle = 'e' + resize_handle = "e" page_size = (210.0, 297.0) - params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy, - resize_handle=resize_handle, page_size=page_size, dpi=300) + params = SnapResizeParams( + position=position, size=size, dx=dx, dy=dy, resize_handle=resize_handle, page_size=page_size, dpi=300 + ) new_pos, new_size = system.snap_resize(params) # Position should stay same @@ -302,6 +303,7 @@ class TestSnappingSystem: def test_snap_resize_minimum_size(self): """Test snap_resize enforces minimum size""" from pyPhotoAlbum.snapping import SnapResizeParams + system = SnappingSystem(snap_threshold_mm=5.0) system.snap_to_edges = False @@ -309,11 +311,12 @@ class TestSnappingSystem: size = (50.0, 50.0) dx = -100.0 # Try to make it very small dy = -100.0 - resize_handle = 'se' + resize_handle = "se" page_size = (210.0, 297.0) - params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy, - resize_handle=resize_handle, page_size=page_size, dpi=300) + params = SnapResizeParams( + position=position, size=size, dx=dx, dy=dy, resize_handle=resize_handle, page_size=page_size, dpi=300 + ) new_pos, new_size = system.snap_resize(params) # Should enforce minimum size of 10 pixels @@ -323,6 +326,7 @@ class TestSnappingSystem: def test_snap_resize_all_handles(self): """Test snap_resize works with all handle types""" from pyPhotoAlbum.snapping import SnapResizeParams + system = SnappingSystem(snap_threshold_mm=5.0) system.snap_to_edges = False @@ -332,11 +336,12 @@ class TestSnappingSystem: dy = 10.0 page_size = (210.0, 297.0) - handles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] + handles = ["nw", "n", "ne", "e", "se", "s", "sw", "w"] for handle in handles: - params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy, - resize_handle=handle, page_size=page_size, dpi=300) + params = SnapResizeParams( + position=position, size=size, dx=dx, dy=dy, resize_handle=handle, page_size=page_size, dpi=300 + ) new_pos, new_size = system.snap_resize(params) # Should return valid position and size assert isinstance(new_pos, tuple) @@ -356,9 +361,9 @@ class TestSnappingSystem: page_size = (210.0, 297.0) lines = system.get_snap_lines(page_size, dpi=300) - assert lines['grid'] == [] - assert lines['edges'] == [] - assert lines['guides'] == [] + assert lines["grid"] == [] + assert lines["edges"] == [] + assert lines["guides"] == [] def test_get_snap_lines_with_grid(self): """Test get_snap_lines with grid enabled""" @@ -370,11 +375,11 @@ class TestSnappingSystem: lines = system.get_snap_lines(page_size, dpi=300) # Should have grid lines - assert len(lines['grid']) > 0 - + assert len(lines["grid"]) > 0 + # Should have both vertical and horizontal grid lines - vertical_lines = [line for line in lines['grid'] if line[0] == 'vertical'] - horizontal_lines = [line for line in lines['grid'] if line[0] == 'horizontal'] + vertical_lines = [line for line in lines["grid"] if line[0] == "vertical"] + horizontal_lines = [line for line in lines["grid"] if line[0] == "horizontal"] assert len(vertical_lines) > 0 assert len(horizontal_lines) > 0 @@ -387,34 +392,34 @@ class TestSnappingSystem: lines = system.get_snap_lines(page_size, dpi=300) # Should have exactly 4 edge lines (left, right, top, bottom) - assert len(lines['edges']) == 4 - + assert len(lines["edges"]) == 4 + # Check for vertical edges - vertical_edges = [line for line in lines['edges'] if line[0] == 'vertical'] + vertical_edges = [line for line in lines["edges"] if line[0] == "vertical"] assert len(vertical_edges) == 2 - + # Check for horizontal edges - horizontal_edges = [line for line in lines['edges'] if line[0] == 'horizontal'] + horizontal_edges = [line for line in lines["edges"] if line[0] == "horizontal"] assert len(horizontal_edges) == 2 def test_get_snap_lines_with_guides(self): """Test get_snap_lines with guides""" system = SnappingSystem() system.snap_to_guides = True - - system.add_guide(position=50.0, orientation='vertical') - system.add_guide(position=100.0, orientation='horizontal') - system.add_guide(position=150.0, orientation='vertical') + + system.add_guide(position=50.0, orientation="vertical") + system.add_guide(position=100.0, orientation="horizontal") + system.add_guide(position=150.0, orientation="vertical") page_size = (210.0, 297.0) lines = system.get_snap_lines(page_size, dpi=300) # Should have guide lines - assert len(lines['guides']) == 3 - + assert len(lines["guides"]) == 3 + # Check orientations - vertical_guides = [line for line in lines['guides'] if line[0] == 'vertical'] - horizontal_guides = [line for line in lines['guides'] if line[0] == 'horizontal'] + vertical_guides = [line for line in lines["guides"] if line[0] == "vertical"] + horizontal_guides = [line for line in lines["guides"] if line[0] == "horizontal"] assert len(vertical_guides) == 2 assert len(horizontal_guides) == 1 @@ -425,35 +430,32 @@ class TestSnappingSystem: system.snap_to_grid = True system.snap_to_edges = False system.snap_to_guides = True - - system.add_guide(position=50.0, orientation='vertical') - system.add_guide(position=100.0, orientation='horizontal') + + system.add_guide(position=50.0, orientation="vertical") + system.add_guide(position=100.0, orientation="horizontal") data = system.serialize() - assert data['snap_threshold_mm'] == 3.0 - assert data['grid_size_mm'] == 15.0 - assert data['snap_to_grid'] == True - assert data['snap_to_edges'] == False - assert data['snap_to_guides'] == True - assert len(data['guides']) == 2 + assert data["snap_threshold_mm"] == 3.0 + assert data["grid_size_mm"] == 15.0 + assert data["snap_to_grid"] == True + assert data["snap_to_edges"] == False + assert data["snap_to_guides"] == True + assert len(data["guides"]) == 2 def test_deserialization(self): """Test SnappingSystem deserialization from dictionary""" system = SnappingSystem() - + data = { - 'snap_threshold_mm': 4.0, - 'grid_size_mm': 20.0, - 'snap_to_grid': True, - 'snap_to_edges': False, - 'snap_to_guides': True, - 'guides': [ - {'position': 50.0, 'orientation': 'vertical'}, - {'position': 100.0, 'orientation': 'horizontal'} - ] + "snap_threshold_mm": 4.0, + "grid_size_mm": 20.0, + "snap_to_grid": True, + "snap_to_edges": False, + "snap_to_guides": True, + "guides": [{"position": 50.0, "orientation": "vertical"}, {"position": 100.0, "orientation": "horizontal"}], } - + system.deserialize(data) assert system.snap_threshold_mm == 4.0 @@ -463,15 +465,15 @@ class TestSnappingSystem: assert system.snap_to_guides == True assert len(system.guides) == 2 assert system.guides[0].position == 50.0 - assert system.guides[0].orientation == 'vertical' + assert system.guides[0].orientation == "vertical" assert system.guides[1].position == 100.0 - assert system.guides[1].orientation == 'horizontal' + assert system.guides[1].orientation == "horizontal" def test_deserialization_with_defaults(self): """Test SnappingSystem deserialization with missing fields uses defaults""" system = SnappingSystem() data = {} - + system.deserialize(data) assert system.snap_threshold_mm == 5.0 @@ -488,10 +490,10 @@ class TestSnappingSystem: original.snap_to_grid = True original.snap_to_edges = True original.snap_to_guides = False - - original.add_guide(position=25.5, orientation='vertical') - original.add_guide(position=75.5, orientation='horizontal') - original.add_guide(position=125.5, orientation='vertical') + + original.add_guide(position=25.5, orientation="vertical") + original.add_guide(position=75.5, orientation="horizontal") + original.add_guide(position=125.5, orientation="vertical") data = original.serialize() restored = SnappingSystem() @@ -503,7 +505,7 @@ class TestSnappingSystem: assert restored.snap_to_edges == original.snap_to_edges assert restored.snap_to_guides == original.snap_to_guides assert len(restored.guides) == len(original.guides) - + for orig_guide, rest_guide in zip(original.guides, restored.guides): assert rest_guide.position == orig_guide.position assert rest_guide.orientation == orig_guide.orientation diff --git a/tests/test_snapping_system.py b/tests/test_snapping_system.py new file mode 100644 index 0000000..615a5bd --- /dev/null +++ b/tests/test_snapping_system.py @@ -0,0 +1,630 @@ +""" +Comprehensive tests for SnappingSystem +""" + +import pytest +import math +from unittest.mock import Mock + +from pyPhotoAlbum.snapping import SnappingSystem, Guide, SnapResizeParams + + +class TestGuide: + """Tests for Guide dataclass""" + + def test_guide_creation(self): + """Test creating a Guide""" + guide = Guide(position=100.0, orientation="vertical") + assert guide.position == 100.0 + assert guide.orientation == "vertical" + + def test_guide_serialize(self): + """Test Guide serialization""" + guide = Guide(position=50.5, orientation="horizontal") + data = guide.serialize() + + assert data["position"] == 50.5 + assert data["orientation"] == "horizontal" + + def test_guide_deserialize(self): + """Test Guide deserialization""" + data = {"position": 75.0, "orientation": "vertical"} + guide = Guide.deserialize(data) + + assert guide.position == 75.0 + assert guide.orientation == "vertical" + + def test_guide_deserialize_defaults(self): + """Test Guide deserialization with missing fields""" + guide = Guide.deserialize({}) + + assert guide.position == 0 + assert guide.orientation == "vertical" + + +class TestSnappingSystemInit: + """Tests for SnappingSystem initialization""" + + def test_default_init(self): + """Test default initialization""" + snap = SnappingSystem() + + assert snap.snap_threshold_mm == 5.0 + assert snap.grid_size_mm == 10.0 + assert snap.snap_to_grid is False + assert snap.snap_to_edges is True + assert snap.snap_to_guides is True + assert snap.guides == [] + + def test_custom_threshold(self): + """Test initialization with custom threshold""" + snap = SnappingSystem(snap_threshold_mm=10.0) + assert snap.snap_threshold_mm == 10.0 + + +class TestGuideManagement: + """Tests for guide management methods""" + + def test_add_guide(self): + """Test adding a guide""" + snap = SnappingSystem() + guide = snap.add_guide(100.0, "vertical") + + assert len(snap.guides) == 1 + assert snap.guides[0].position == 100.0 + assert snap.guides[0].orientation == "vertical" + + def test_add_multiple_guides(self): + """Test adding multiple guides""" + snap = SnappingSystem() + snap.add_guide(50.0, "horizontal") + snap.add_guide(100.0, "vertical") + snap.add_guide(150.0, "horizontal") + + assert len(snap.guides) == 3 + + def test_remove_guide(self): + """Test removing a guide""" + snap = SnappingSystem() + guide = snap.add_guide(100.0, "vertical") + + snap.remove_guide(guide) + + assert len(snap.guides) == 0 + + def test_remove_nonexistent_guide(self): + """Test removing a guide that doesn't exist""" + snap = SnappingSystem() + guide = Guide(position=100.0, orientation="vertical") + + # Should not raise exception + snap.remove_guide(guide) + assert len(snap.guides) == 0 + + def test_clear_guides(self): + """Test clearing all guides""" + snap = SnappingSystem() + snap.add_guide(50.0, "horizontal") + snap.add_guide(100.0, "vertical") + snap.add_guide(150.0, "horizontal") + + snap.clear_guides() + + assert len(snap.guides) == 0 + + +class TestSnapPosition: + """Tests for snap_position method""" + + def test_no_snapping_when_disabled(self): + """Test that no snapping occurs when all snapping is disabled""" + snap = SnappingSystem() + snap.snap_to_grid = False + snap.snap_to_edges = False + snap.snap_to_guides = False + + position = (100, 100) + size = (50, 50) + page_size = (210, 297) # A4 in mm + + result = snap.snap_position(position, size, page_size) + + assert result == position + + def test_snap_to_left_edge(self): + """Test snapping to left page edge""" + snap = SnappingSystem(snap_threshold_mm=10.0) + snap.snap_to_edges = True + snap.snap_to_grid = False + snap.snap_to_guides = False + + # Position close to left edge (within threshold) + # At 300 DPI, 10mm = ~118 pixels + position = (5, 100) # Very close to left edge (0) + size = (50, 50) + page_size = (210, 297) + + result = snap.snap_position(position, size, page_size) + + # Should snap to 0 (left edge) + assert result[0] == 0 + + def test_snap_to_right_edge(self): + """Test snapping to right page edge""" + snap = SnappingSystem(snap_threshold_mm=10.0) + snap.snap_to_edges = True + snap.snap_to_grid = False + snap.snap_to_guides = False + + dpi = 300 + page_width_mm = 210 + page_width_px = page_width_mm * dpi / 25.4 + element_width = 50 + + # Position close to right edge + position = (page_width_px - element_width - 5, 100) + size = (element_width, 50) + page_size = (page_width_mm, 297) + + result = snap.snap_position(position, size, page_size, dpi) + + # Should snap so element's right edge aligns with page right edge + expected_x = page_width_px - element_width + assert abs(result[0] - expected_x) < 1 + + def test_snap_to_grid(self): + """Test snapping to grid""" + snap = SnappingSystem(snap_threshold_mm=5.0) + snap.snap_to_grid = True + snap.snap_to_edges = False + snap.snap_to_guides = False + snap.grid_size_mm = 10.0 + + dpi = 300 + grid_size_px = 10.0 * dpi / 25.4 # ~118 pixels + + # Position slightly off grid + position = (grid_size_px + 10, grid_size_px + 10) + size = (50, 50) + page_size = (210, 297) + + result = snap.snap_position(position, size, page_size, dpi) + + # Should snap to nearest grid intersection + assert abs(result[0] - grid_size_px) < 1 or abs(result[0] - 2 * grid_size_px) < 1 + + def test_snap_to_guides(self): + """Test snapping to guides""" + snap = SnappingSystem(snap_threshold_mm=10.0) + snap.snap_to_edges = False + snap.snap_to_grid = False + snap.snap_to_guides = True + + dpi = 300 + guide_pos_mm = 50.0 + guide_pos_px = guide_pos_mm * dpi / 25.4 + + snap.add_guide(guide_pos_mm, "vertical") + snap.add_guide(guide_pos_mm, "horizontal") + + # Position close to guide intersection + position = (guide_pos_px + 5, guide_pos_px + 5) + size = (50, 50) + page_size = (210, 297) + + result = snap.snap_position(position, size, page_size, dpi) + + # Should snap to guide intersection + assert abs(result[0] - guide_pos_px) < 1 or abs(result[1] - guide_pos_px) < 1 + + def test_snap_uses_euclidean_distance(self): + """Test that snapping uses Euclidean distance for point selection""" + snap = SnappingSystem(snap_threshold_mm=20.0) + snap.snap_to_edges = True + snap.snap_to_grid = False + snap.snap_to_guides = False + + # Position close to origin - should snap to (0, 0) + # At 300 DPI, 20mm threshold = ~236 pixels + # Position (50, 50) has euclidean distance ~70.7 from (0, 0) + # which is well within the threshold + position = (50, 50) + size = (50, 50) + page_size = (210, 297) + dpi = 300 + + result = snap.snap_position(position, size, page_size, dpi) + + # Should snap to (0, 0) corner as it's closest and within threshold + # Note: snap_position considers multiple snap points; check we got one of them + assert result[0] == 0 or result[1] == 0, f"Expected at least one axis to snap to 0, got {result}" + + def test_snap_with_project_settings(self): + """Test snapping with project settings override""" + snap = SnappingSystem() + snap.snap_to_grid = False # Local setting + + mock_project = Mock() + mock_project.snap_to_grid = True + mock_project.snap_to_edges = False + mock_project.snap_to_guides = False + mock_project.grid_size_mm = 10.0 + mock_project.snap_threshold_mm = 5.0 + + dpi = 300 + grid_size_px = 10.0 * dpi / 25.4 + + # Position near grid line + position = (grid_size_px + 5, grid_size_px + 5) + size = (50, 50) + page_size = (210, 297) + + result = snap.snap_position(position, size, page_size, dpi, mock_project) + + # Should use project settings and snap to grid + # The result should be different from input (snapped) + assert result != position + + +class TestSnapResize: + """Tests for snap_resize method""" + + def test_resize_southeast_handle(self): + """Test resizing from SE corner""" + snap = SnappingSystem() + snap.snap_to_edges = False + snap.snap_to_grid = False + snap.snap_to_guides = False + + params = SnapResizeParams( + position=(100, 100), size=(100, 100), dx=50, dy=50, resize_handle="se", page_size=(210, 297) + ) + + new_pos, new_size = snap.snap_resize(params) + + # Position should stay same for SE resize + assert new_pos == (100, 100) + # Size should increase + assert new_size == (150, 150) + + def test_resize_northwest_handle(self): + """Test resizing from NW corner""" + snap = SnappingSystem() + snap.snap_to_edges = False + snap.snap_to_grid = False + snap.snap_to_guides = False + + params = SnapResizeParams( + position=(100, 100), size=(100, 100), dx=-20, dy=-20, resize_handle="nw", page_size=(210, 297) + ) + + new_pos, new_size = snap.snap_resize(params) + + # Position should move for NW resize + assert new_pos == (80, 80) + # Size should increase + assert new_size == (120, 120) + + def test_resize_minimum_size(self): + """Test that resize enforces minimum size""" + snap = SnappingSystem() + snap.snap_to_edges = False + snap.snap_to_grid = False + snap.snap_to_guides = False + + params = SnapResizeParams( + position=(100, 100), + size=(50, 50), + dx=-100, # Would make width negative + dy=-100, # Would make height negative + resize_handle="se", + page_size=(210, 297), + ) + + new_pos, new_size = snap.snap_resize(params) + + # Size should be clamped to minimum + assert new_size[0] >= 10 + assert new_size[1] >= 10 + + def test_resize_snap_to_edge(self): + """Test that resize snaps edges to page boundaries""" + snap = SnappingSystem(snap_threshold_mm=10.0) + snap.snap_to_edges = True + snap.snap_to_grid = False + snap.snap_to_guides = False + + dpi = 300 + page_width_px = 210 * dpi / 25.4 + + params = SnapResizeParams( + position=(100, 100), + size=(100, 100), + dx=page_width_px - 200 - 5, # Almost to right edge + dy=0, + resize_handle="e", + page_size=(210, 297), + dpi=dpi, + ) + + new_pos, new_size = snap.snap_resize(params) + + # Right edge should snap to page edge + right_edge = new_pos[0] + new_size[0] + assert abs(right_edge - page_width_px) < 20 # Within snap threshold + + +class TestSnapEdgeToTargets: + """Tests for _snap_edge_to_targets method""" + + def test_snap_to_page_start_edge(self): + """Test snapping to page start edge (0)""" + snap = SnappingSystem() + snap.snap_to_edges = True + snap.snap_to_grid = False + snap.snap_to_guides = False + + dpi = 300 + threshold_px = 50 + + result = snap._snap_edge_to_targets( + edge_position=10, page_size_mm=210, dpi=dpi, snap_threshold_px=threshold_px, orientation="vertical" + ) + + assert result == 0 + + def test_snap_to_page_end_edge(self): + """Test snapping to page end edge""" + snap = SnappingSystem() + snap.snap_to_edges = True + snap.snap_to_grid = False + snap.snap_to_guides = False + + dpi = 300 + page_size_mm = 210 + page_size_px = page_size_mm * dpi / 25.4 + threshold_px = 50 + + result = snap._snap_edge_to_targets( + edge_position=page_size_px - 10, + page_size_mm=page_size_mm, + dpi=dpi, + snap_threshold_px=threshold_px, + orientation="vertical", + ) + + assert result == page_size_px + + def test_snap_to_grid_line(self): + """Test snapping to grid line""" + snap = SnappingSystem() + snap.snap_to_edges = False + snap.snap_to_grid = True + snap.snap_to_guides = False + snap.grid_size_mm = 10.0 + + dpi = 300 + grid_size_px = 10.0 * dpi / 25.4 + threshold_px = 50 + + result = snap._snap_edge_to_targets( + edge_position=grid_size_px + 5, + page_size_mm=210, + dpi=dpi, + snap_threshold_px=threshold_px, + orientation="vertical", + ) + + assert result == grid_size_px + + def test_snap_to_guide(self): + """Test snapping to guide""" + snap = SnappingSystem() + snap.snap_to_edges = False + snap.snap_to_grid = False + snap.snap_to_guides = True + + guide_pos_mm = 50.0 + snap.add_guide(guide_pos_mm, "vertical") + + dpi = 300 + guide_pos_px = guide_pos_mm * dpi / 25.4 + threshold_px = 50 + + result = snap._snap_edge_to_targets( + edge_position=guide_pos_px + 5, + page_size_mm=210, + dpi=dpi, + snap_threshold_px=threshold_px, + orientation="vertical", + ) + + assert result == guide_pos_px + + def test_no_snap_when_out_of_threshold(self): + """Test no snap when edge is outside threshold""" + snap = SnappingSystem() + snap.snap_to_edges = True + snap.snap_to_grid = False + snap.snap_to_guides = False + + dpi = 300 + threshold_px = 10 + + result = snap._snap_edge_to_targets( + edge_position=500, # Far from any edge + page_size_mm=210, + dpi=dpi, + snap_threshold_px=threshold_px, + orientation="vertical", + ) + + assert result is None + + +class TestSnapAxis: + """Tests for _snap_axis method""" + + def test_snap_axis_to_start(self): + """Test snapping axis to start edge""" + snap = SnappingSystem() + snap.snap_to_edges = True + snap.snap_to_grid = False + snap.snap_to_guides = False + + dpi = 300 + threshold_px = 50 + + result = snap._snap_axis( + position=10, size=50, page_size_mm=210, dpi=dpi, snap_threshold_px=threshold_px, orientation="vertical" + ) + + assert result == 0 + + def test_snap_axis_to_end(self): + """Test snapping axis so element end aligns with page end""" + snap = SnappingSystem() + snap.snap_to_edges = True + snap.snap_to_grid = False + snap.snap_to_guides = False + + dpi = 300 + page_size_mm = 210 + page_size_px = page_size_mm * dpi / 25.4 + element_size = 50 + threshold_px = 50 + + result = snap._snap_axis( + position=page_size_px - element_size - 10, + size=element_size, + page_size_mm=page_size_mm, + dpi=dpi, + snap_threshold_px=threshold_px, + orientation="vertical", + ) + + expected = page_size_px - element_size + assert abs(result - expected) < 1 + + +class TestGetSnapLines: + """Tests for get_snap_lines method""" + + def test_get_snap_lines_edges_only(self): + """Test getting snap lines with edges only""" + snap = SnappingSystem() + snap.snap_to_edges = True + snap.snap_to_grid = False + snap.snap_to_guides = False + + result = snap.get_snap_lines((210, 297)) + + assert len(result["edges"]) == 4 # 4 edges + assert len(result["grid"]) == 0 + assert len(result["guides"]) == 0 + + def test_get_snap_lines_with_grid(self): + """Test getting snap lines with grid enabled""" + snap = SnappingSystem() + snap.snap_to_edges = False + snap.snap_to_grid = True + snap.snap_to_guides = False + snap.grid_size_mm = 10.0 + + result = snap.get_snap_lines((100, 100), dpi=300) + + # Should have multiple grid lines + assert len(result["grid"]) > 0 + assert len(result["edges"]) == 0 + + def test_get_snap_lines_with_guides(self): + """Test getting snap lines with guides""" + snap = SnappingSystem() + snap.snap_to_edges = False + snap.snap_to_grid = False + snap.snap_to_guides = True + + snap.add_guide(50.0, "vertical") + snap.add_guide(100.0, "horizontal") + + result = snap.get_snap_lines((210, 297)) + + assert len(result["guides"]) == 2 + + +class TestSerialization: + """Tests for serialize/deserialize methods""" + + def test_serialize(self): + """Test serialization""" + snap = SnappingSystem(snap_threshold_mm=8.0) + snap.grid_size_mm = 15.0 + snap.snap_to_grid = True + snap.snap_to_edges = False + snap.add_guide(50.0, "vertical") + snap.add_guide(100.0, "horizontal") + + data = snap.serialize() + + assert data["snap_threshold_mm"] == 8.0 + assert data["grid_size_mm"] == 15.0 + assert data["snap_to_grid"] is True + assert data["snap_to_edges"] is False + assert data["snap_to_guides"] is True + assert len(data["guides"]) == 2 + + def test_deserialize(self): + """Test deserialization""" + snap = SnappingSystem() + + data = { + "snap_threshold_mm": 12.0, + "grid_size_mm": 20.0, + "snap_to_grid": True, + "snap_to_edges": False, + "snap_to_guides": False, + "guides": [{"position": 75.0, "orientation": "vertical"}, {"position": 125.0, "orientation": "horizontal"}], + } + + snap.deserialize(data) + + assert snap.snap_threshold_mm == 12.0 + assert snap.grid_size_mm == 20.0 + assert snap.snap_to_grid is True + assert snap.snap_to_edges is False + assert snap.snap_to_guides is False + assert len(snap.guides) == 2 + assert snap.guides[0].position == 75.0 + assert snap.guides[1].orientation == "horizontal" + + def test_serialize_deserialize_roundtrip(self): + """Test serialize/deserialize roundtrip""" + original = SnappingSystem(snap_threshold_mm=7.5) + original.grid_size_mm = 12.5 + original.snap_to_grid = True + original.add_guide(33.0, "vertical") + original.add_guide(66.0, "horizontal") + + data = original.serialize() + + restored = SnappingSystem() + restored.deserialize(data) + + assert restored.snap_threshold_mm == original.snap_threshold_mm + assert restored.grid_size_mm == original.grid_size_mm + assert restored.snap_to_grid == original.snap_to_grid + assert restored.snap_to_edges == original.snap_to_edges + assert restored.snap_to_guides == original.snap_to_guides + assert len(restored.guides) == len(original.guides) + + def test_deserialize_defaults(self): + """Test deserialization with missing fields uses defaults""" + snap = SnappingSystem() + snap.deserialize({}) + + assert snap.snap_threshold_mm == 5.0 + assert snap.grid_size_mm == 10.0 + assert snap.snap_to_grid is False + assert snap.snap_to_edges is True + assert snap.snap_to_guides is True + assert snap.guides == [] diff --git a/tests/test_template_manager.py b/tests/test_template_manager.py index 5c015d4..d110522 100755 --- a/tests/test_template_manager.py +++ b/tests/test_template_manager.py @@ -25,11 +25,7 @@ class TestTemplate: def test_initialization_with_parameters(self): """Test Template initialization with custom parameters""" - template = Template( - name="My Template", - description="Test template", - page_size_mm=(200, 280) - ) + template = Template(name="My Template", description="Test template", page_size_mm=(200, 280)) assert template.name == "My Template" assert template.description == "Test template" assert template.page_size_mm == (200, 280) @@ -38,7 +34,7 @@ class TestTemplate: """Test adding elements to template""" template = Template() placeholder = PlaceholderData(x=10, y=20, width=100, height=50) - + template.add_element(placeholder) assert len(template.elements) == 1 assert template.elements[0] == placeholder @@ -48,10 +44,10 @@ class TestTemplate: template = Template() elem1 = PlaceholderData(x=10, y=20, width=100, height=50) elem2 = TextBoxData(text_content="Test", x=30, y=40, width=150, height=60) - + template.add_element(elem1) template.add_element(elem2) - + assert len(template.elements) == 2 assert elem1 in template.elements assert elem2 in template.elements @@ -61,9 +57,9 @@ class TestTemplate: template = Template(name="Test", description="Desc", page_size_mm=(200, 280)) placeholder = PlaceholderData(x=10, y=20, width=100, height=50) template.add_element(placeholder) - + data = template.to_dict() - + assert data["name"] == "Test" assert data["description"] == "Desc" assert data["page_size_mm"] == (200, 280) @@ -77,23 +73,13 @@ class TestTemplate: "description": "Test description", "page_size_mm": [220, 300], "elements": [ - { - "type": "placeholder", - "position": (50, 60), - "size": (120, 80), - "placeholder_type": "image" - }, - { - "type": "textbox", - "position": (70, 90), - "size": (140, 100), - "text_content": "Test text" - } - ] + {"type": "placeholder", "position": (50, 60), "size": (120, 80), "placeholder_type": "image"}, + {"type": "textbox", "position": (70, 90), "size": (140, 100), "text_content": "Test text"}, + ], } - + template = Template.from_dict(data) - + assert template.name == "Loaded Template" assert template.description == "Test description" assert template.page_size_mm == (220, 300) @@ -107,12 +93,12 @@ class TestTemplate: "name": "Test", "elements": [ {"type": "image", "position": (10, 20), "size": (100, 50)}, - {"type": "placeholder", "position": (30, 40), "size": (120, 60)} - ] + {"type": "placeholder", "position": (30, 40), "size": (120, 60)}, + ], } - + template = Template.from_dict(data) - + # Should only have the placeholder, not the image assert len(template.elements) == 1 assert isinstance(template.elements[0], PlaceholderData) @@ -122,15 +108,15 @@ class TestTemplate: template = Template(name="Save Test", description="Test save") placeholder = PlaceholderData(x=10, y=20, width=100, height=50) template.add_element(placeholder) - + file_path = Path(temp_dir) / "test_template.json" template.save_to_file(str(file_path)) - + # Verify file was created assert file_path.exists() - + # Verify content - with open(file_path, 'r') as f: + with open(file_path, "r") as f: data = json.load(f) assert data["name"] == "Save Test" assert data["description"] == "Test save" @@ -142,23 +128,16 @@ class TestTemplate: "name": "Load Test", "description": "Test load", "page_size_mm": [210, 297], - "elements": [ - { - "type": "placeholder", - "position": (10, 20), - "size": (100, 50), - "placeholder_type": "image" - } - ] + "elements": [{"type": "placeholder", "position": (10, 20), "size": (100, 50), "placeholder_type": "image"}], } - + file_path = Path(temp_dir) / "load_test.json" - with open(file_path, 'w') as f: + with open(file_path, "w") as f: json.dump(data, f) - + # Load template template = Template.load_from_file(str(file_path)) - + assert template.name == "Load Test" assert template.description == "Test load" assert len(template.elements) == 1 @@ -177,7 +156,7 @@ class TestTemplateManager: """Test getting templates directory""" manager = TemplateManager() templates_dir = manager._get_templates_directory() - + assert templates_dir.name == "templates" assert ".pyphotoalbum" in str(templates_dir) @@ -185,7 +164,7 @@ class TestTemplateManager: """Test getting built-in templates directory""" manager = TemplateManager() builtin_dir = manager._get_builtin_templates_directory() - + assert builtin_dir.name == "templates" assert "pyPhotoAlbum" in str(builtin_dir) @@ -196,11 +175,11 @@ class TestTemplateManager: builtin_dir = tmp_path / "builtin_templates" user_dir.mkdir() builtin_dir.mkdir() - + manager = TemplateManager() - monkeypatch.setattr(manager, 'templates_dir', user_dir) - monkeypatch.setattr(manager, '_get_builtin_templates_directory', lambda: builtin_dir) - + monkeypatch.setattr(manager, "templates_dir", user_dir) + monkeypatch.setattr(manager, "_get_builtin_templates_directory", lambda: builtin_dir) + templates = manager.list_templates() assert templates == [] @@ -210,21 +189,21 @@ class TestTemplateManager: builtin_dir = tmp_path / "builtin_templates" user_dir.mkdir() builtin_dir.mkdir() - + # Create user template user_template = user_dir / "My_Template.json" user_template.write_text('{"name": "My Template"}') - + # Create built-in template builtin_template = builtin_dir / "Grid_2x2.json" builtin_template.write_text('{"name": "Grid 2x2"}') - + manager = TemplateManager() - monkeypatch.setattr(manager, 'templates_dir', user_dir) - monkeypatch.setattr(manager, '_get_builtin_templates_directory', lambda: builtin_dir) - + monkeypatch.setattr(manager, "templates_dir", user_dir) + monkeypatch.setattr(manager, "_get_builtin_templates_directory", lambda: builtin_dir) + templates = manager.list_templates() - + assert "[Built-in] Grid_2x2" in templates assert "My_Template" in templates assert len(templates) == 2 @@ -233,13 +212,13 @@ class TestTemplateManager: """Test saving a template""" user_dir = tmp_path / "user_templates" user_dir.mkdir() - + manager = TemplateManager() - monkeypatch.setattr(manager, 'templates_dir', user_dir) - + monkeypatch.setattr(manager, "templates_dir", user_dir) + template = Template(name="Test Template") manager.save_template(template) - + # Verify file was created template_file = user_dir / "Test Template.json" assert template_file.exists() @@ -248,16 +227,16 @@ class TestTemplateManager: """Test loading a user template""" user_dir = tmp_path / "user_templates" user_dir.mkdir() - + # Create template file data = {"name": "User Template", "description": "Test", "page_size_mm": [210, 297], "elements": []} template_file = user_dir / "User Template.json" - with open(template_file, 'w') as f: + with open(template_file, "w") as f: json.dump(data, f) - + manager = TemplateManager() - monkeypatch.setattr(manager, 'templates_dir', user_dir) - + monkeypatch.setattr(manager, "templates_dir", user_dir) + template = manager.load_template("User Template") assert template.name == "User Template" @@ -265,16 +244,16 @@ class TestTemplateManager: """Test loading a built-in template""" builtin_dir = tmp_path / "builtin_templates" builtin_dir.mkdir() - + # Create built-in template file data = {"name": "Grid 2x2", "description": "Built-in grid", "page_size_mm": [210, 297], "elements": []} template_file = builtin_dir / "Grid 2x2.json" - with open(template_file, 'w') as f: + with open(template_file, "w") as f: json.dump(data, f) - + manager = TemplateManager() - monkeypatch.setattr(manager, '_get_builtin_templates_directory', lambda: builtin_dir) - + monkeypatch.setattr(manager, "_get_builtin_templates_directory", lambda: builtin_dir) + template = manager.load_template("[Built-in] Grid 2x2") assert template.name == "Grid 2x2" @@ -282,10 +261,10 @@ class TestTemplateManager: """Test loading non-existent template raises error""" user_dir = tmp_path / "user_templates" user_dir.mkdir() - + manager = TemplateManager() - monkeypatch.setattr(manager, 'templates_dir', user_dir) - + monkeypatch.setattr(manager, "templates_dir", user_dir) + with pytest.raises(FileNotFoundError): manager.load_template("NonExistent") @@ -293,21 +272,21 @@ class TestTemplateManager: """Test deleting a user template""" user_dir = tmp_path / "user_templates" user_dir.mkdir() - + # Create template file template_file = user_dir / "DeleteMe.json" template_file.write_text('{"name": "DeleteMe"}') - + manager = TemplateManager() - monkeypatch.setattr(manager, 'templates_dir', user_dir) - + monkeypatch.setattr(manager, "templates_dir", user_dir) + manager.delete_template("DeleteMe") assert not template_file.exists() def test_delete_builtin_template_raises_error(self): """Test deleting built-in template raises error""" manager = TemplateManager() - + with pytest.raises(PermissionError): manager.delete_template("[Built-in] Grid_2x2") @@ -318,25 +297,21 @@ class TestTemplateManager: img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50) text = TextBoxData(text_content="Test", x=30, y=40, width=150, height=60) placeholder = PlaceholderData(x=50, y=60, width=120, height=70) - + layout.add_element(img) layout.add_element(text) layout.add_element(placeholder) - + page = Page(layout=layout, page_number=1) - + # Create template manager = TemplateManager() - template = manager.create_template_from_page( - page, - name="Test Template", - description="Created from page" - ) - + template = manager.create_template_from_page(page, name="Test Template", description="Created from page") + assert template.name == "Test Template" assert template.description == "Created from page" assert len(template.elements) == 3 - + # Image should be converted to placeholder assert isinstance(template.elements[0], PlaceholderData) assert isinstance(template.elements[1], TextBoxData) @@ -352,10 +327,7 @@ class TestTemplateManager: # Scale to 400x400 (2x scale) - results in pixels at 300 DPI scaled = manager.scale_template_elements( - elements, - from_size=(200, 200), - to_size=(400, 400), - scale_mode="proportional" + elements, from_size=(200, 200), to_size=(400, 400), scale_mode="proportional" ) assert len(scaled) == 1 @@ -379,10 +351,7 @@ class TestTemplateManager: # Scale to 400x200 (2x width, 1x height) - results in pixels at 300 DPI scaled = manager.scale_template_elements( - elements, - from_size=(200, 200), - to_size=(400, 200), - scale_mode="stretch" + elements, from_size=(200, 200), to_size=(400, 200), scale_mode="stretch" ) assert len(scaled) == 1 @@ -403,10 +372,7 @@ class TestTemplateManager: # Center in larger space without scaling - results in pixels at 300 DPI scaled = manager.scale_template_elements( - elements, - from_size=(200, 200), - to_size=(400, 400), - scale_mode="center" + elements, from_size=(200, 200), to_size=(400, 400), scale_mode="center" ) assert len(scaled) == 1 @@ -422,19 +388,16 @@ class TestTemplateManager: def test_scale_template_preserves_properties(self): """Test that scaling preserves element properties""" manager = TemplateManager() - + elem = PlaceholderData(x=50, y=50, width=100, height=100) elem.rotation = 45 elem.z_index = 5 elem.placeholder_type = "image" - + scaled = manager.scale_template_elements( - [elem], - from_size=(200, 200), - to_size=(400, 400), - scale_mode="proportional" + [elem], from_size=(200, 200), to_size=(400, 400), scale_mode="proportional" ) - + assert scaled[0].rotation == 45 assert scaled[0].z_index == 5 assert scaled[0].placeholder_type == "image" @@ -442,19 +405,19 @@ class TestTemplateManager: def test_apply_template_to_page_replace(self): """Test applying template with replace mode""" manager = TemplateManager() - + # Create template template = Template(page_size_mm=(200, 200)) template.add_element(PlaceholderData(x=10, y=20, width=80, height=60)) - + # Create page with existing content layout = PageLayout(width=200, height=200) layout.add_element(ImageData(x=100, y=100, width=50, height=50)) page = Page(layout=layout, page_number=1) - + # Apply template manager.apply_template_to_page(template, page, mode="replace") - + # Page should have only template elements assert len(page.layout.elements) == 1 assert isinstance(page.layout.elements[0], PlaceholderData) @@ -462,21 +425,21 @@ class TestTemplateManager: def test_apply_template_to_page_reflow(self): """Test applying template with reflow mode""" manager = TemplateManager() - + # Create template with 2 placeholders template = Template(page_size_mm=(200, 200)) template.add_element(PlaceholderData(x=10, y=20, width=80, height=60)) template.add_element(PlaceholderData(x=100, y=100, width=80, height=60)) - + # Create page with 1 image layout = PageLayout(width=200, height=200) img = ImageData(image_path="test.jpg", x=50, y=50, width=50, height=50) layout.add_element(img) page = Page(layout=layout, page_number=1) - + # Apply template with reflow manager.apply_template_to_page(template, page, mode="reflow") - + # Should have 1 image (reflowed) + 1 placeholder assert len(page.layout.elements) == 2 # First should be the reflowed image @@ -487,14 +450,14 @@ class TestTemplateManager: def test_create_page_from_template_default_size(self): """Test creating page from template with default size""" manager = TemplateManager() - + # Create template template = Template(page_size_mm=(210, 297)) template.add_element(PlaceholderData(x=10, y=20, width=100, height=50)) - + # Create page page = manager.create_page_from_template(template, page_number=5) - + assert page.page_number == 5 assert page.layout.size == (210, 297) assert len(page.layout.elements) == 1 @@ -510,11 +473,7 @@ class TestTemplateManager: # Create page at 400x400 with 0% margin for exact 2x scaling page = manager.create_page_from_template( - template, - page_number=1, - target_size_mm=(400, 400), - scale_mode="proportional", - margin_percent=0.0 + template, page_number=1, target_size_mm=(400, 400), scale_mode="proportional", margin_percent=0.0 ) assert page.layout.size == (400, 400) @@ -529,24 +488,14 @@ class TestTemplateManager: def test_scale_with_textbox_preserves_font_settings(self): """Test that scaling preserves text box font settings""" manager = TemplateManager() - + font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)} - text = TextBoxData( - text_content="Test", - font_settings=font_settings, - x=50, - y=50, - width=100, - height=50 - ) - + text = TextBoxData(text_content="Test", font_settings=font_settings, x=50, y=50, width=100, height=50) + scaled = manager.scale_template_elements( - [text], - from_size=(200, 200), - to_size=(400, 400), - scale_mode="proportional" + [text], from_size=(200, 200), to_size=(400, 400), scale_mode="proportional" ) - + assert scaled[0].text_content == "Test" assert scaled[0].font_settings == font_settings assert scaled[0].alignment == text.alignment @@ -567,12 +516,7 @@ class TestTemplateManager: layout = PageLayout(width=210, height=210) page = Page(layout=layout, page_number=1) - manager.apply_template_to_page( - template, page, - mode="replace", - scale_mode="stretch", - margin_percent=2.5 - ) + manager.apply_template_to_page(template, page, mode="replace", scale_mode="stretch", margin_percent=2.5) # With 2.5% margin on 210mm page: margin = 5.25mm, content area = 199.5mm # Template is 200mm, so scale = 199.5 / 200 = 0.9975 @@ -615,12 +559,7 @@ class TestTemplateManager: layout = PageLayout(width=210, height=297) page = Page(layout=layout, page_number=1) - manager.apply_template_to_page( - template, page, - mode="replace", - scale_mode="stretch", - margin_percent=2.5 - ) + manager.apply_template_to_page(template, page, mode="replace", scale_mode="stretch", margin_percent=2.5) # With 2.5% margin: x_margin = 5.25mm, y_margin = 7.425mm # Content area: 199.5 x 282.15mm @@ -661,12 +600,7 @@ class TestTemplateManager: layout = PageLayout(width=210, height=210) page = Page(layout=layout, page_number=1) - manager.apply_template_to_page( - template, page, - mode="replace", - scale_mode="stretch", - margin_percent=0.0 - ) + manager.apply_template_to_page(template, page, mode="replace", scale_mode="stretch", margin_percent=0.0) # With 0% margin: scale = 210/200 = 1.05, offset = 0 # Results are converted to pixels at 300 DPI @@ -681,12 +615,7 @@ class TestTemplateManager: layout2 = PageLayout(width=210, height=210) page2 = Page(layout=layout2, page_number=1) - manager.apply_template_to_page( - template, page2, - mode="replace", - scale_mode="stretch", - margin_percent=5.0 - ) + manager.apply_template_to_page(template, page2, mode="replace", scale_mode="stretch", margin_percent=5.0) # With 5% margin: margin = 10.5mm, content = 189mm, scale = 189/200 = 0.945 # Results are converted to pixels at 300 DPI @@ -710,12 +639,7 @@ class TestTemplateManager: layout = PageLayout(width=210, height=297) page = Page(layout=layout, page_number=1) - manager.apply_template_to_page( - template, page, - mode="replace", - scale_mode="proportional", - margin_percent=2.5 - ) + manager.apply_template_to_page(template, page, mode="replace", scale_mode="proportional", margin_percent=2.5) # With proportional mode on 210x297 page: # Content area: 199.5 x 282.15mm @@ -750,22 +674,22 @@ class TestTemplateManager: # Add various elements with specific sizes (in pixels) # Using pixel positions that correspond to reasonable mm values - img1 = ImageData(image_path="test1.jpg", x=10*mm_to_px, y=20*mm_to_px, width=100*mm_to_px, height=75*mm_to_px) - img2 = ImageData(image_path="test2.jpg", x=120*mm_to_px, y=30*mm_to_px, width=80*mm_to_px, height=60*mm_to_px) + img1 = ImageData( + image_path="test1.jpg", x=10 * mm_to_px, y=20 * mm_to_px, width=100 * mm_to_px, height=75 * mm_to_px + ) + img2 = ImageData( + image_path="test2.jpg", x=120 * mm_to_px, y=30 * mm_to_px, width=80 * mm_to_px, height=60 * mm_to_px + ) text1 = TextBoxData( text_content="Test Text", - x=30*mm_to_px, - y=150*mm_to_px, - width=150*mm_to_px, - height=40*mm_to_px, - font_settings={"family": "Arial", "size": 12} + x=30 * mm_to_px, + y=150 * mm_to_px, + width=150 * mm_to_px, + height=40 * mm_to_px, + font_settings={"family": "Arial", "size": 12}, ) placeholder1 = PlaceholderData( - placeholder_type="image", - x=50*mm_to_px, - y=220*mm_to_px, - width=110*mm_to_px, - height=60*mm_to_px + placeholder_type="image", x=50 * mm_to_px, y=220 * mm_to_px, width=110 * mm_to_px, height=60 * mm_to_px ) layout.add_element(img1) @@ -778,19 +702,19 @@ class TestTemplateManager: # Store original element data original_elements_data = [] for elem in original_page.layout.elements: - original_elements_data.append({ - 'type': type(elem).__name__, - 'position': elem.position, - 'size': elem.size, - 'rotation': elem.rotation, - 'z_index': elem.z_index - }) + original_elements_data.append( + { + "type": type(elem).__name__, + "position": elem.position, + "size": elem.size, + "rotation": elem.rotation, + "z_index": elem.z_index, + } + ) # Create a template from the page template = manager.create_template_from_page( - original_page, - name="Roundtrip Test Template", - description="Testing size preservation" + original_page, name="Roundtrip Test Template", description="Testing size preservation" ) # Create a new page with the same size @@ -800,11 +724,7 @@ class TestTemplateManager: # Apply the template to the new page with no margins and proportional scaling # This should result in identical sizes since page sizes match manager.apply_template_to_page( - template, - new_page, - mode="replace", - scale_mode="proportional", - margin_percent=0.0 + template, new_page, mode="replace", scale_mode="proportional", margin_percent=0.0 ) # Verify we have the same number of elements diff --git a/tests/test_view_ops_mixin.py b/tests/test_view_ops_mixin.py index afa3170..ec75a11 100755 --- a/tests/test_view_ops_mixin.py +++ b/tests/test_view_ops_mixin.py @@ -33,7 +33,7 @@ class TestViewWindow(ViewOperationsMixin, QMainWindow): self._status_message = None def get_current_page(self): - if hasattr(self, '_current_page'): + if hasattr(self, "_current_page"): return self._current_page return None @@ -289,8 +289,8 @@ class TestGuideOperations: layout = PageLayout() # Add some guides - layout.snapping_system.add_guide(100, 'vertical') - layout.snapping_system.add_guide(150, 'horizontal') + layout.snapping_system.add_guide(100, "vertical") + layout.snapping_system.add_guide(150, "horizontal") page = Mock() page.layout = layout window._current_page = page @@ -357,7 +357,7 @@ class TestGridSettingsDialog: mock_dialog = Mock(spec=QDialog) mock_dialog.exec.return_value = QDialog.DialogCode.Rejected - with patch('PyQt6.QtWidgets.QDialog', return_value=mock_dialog): + with patch("PyQt6.QtWidgets.QDialog", return_value=mock_dialog): window.set_grid_size() # Dialog should have been created and exec called @@ -426,7 +426,7 @@ class TestLayoutTabDelegation: mock_dialog = Mock(spec=QDialog) mock_dialog.exec.return_value = QDialog.DialogCode.Rejected - with patch('PyQt6.QtWidgets.QDialog', return_value=mock_dialog): + with patch("PyQt6.QtWidgets.QDialog", return_value=mock_dialog): window.layout_set_grid_size() # Verify method was called (dialog creation attempted) diff --git a/tests/test_viewport_mixin.py b/tests/test_viewport_mixin.py index 1a8c1de..26dcc18 100755 --- a/tests/test_viewport_mixin.py +++ b/tests/test_viewport_mixin.py @@ -14,6 +14,7 @@ from pyPhotoAlbum.page_layout import PageLayout # Create a minimal test widget class class TestViewportWidget(ViewportMixin, QOpenGLWidget): """Test widget combining ViewportMixin with QOpenGLWidget""" + pass @@ -90,10 +91,7 @@ class TestViewportCalculations: mock_window.project.working_dpi = 96 # A4 page: 210mm x 297mm - page = Page( - layout=PageLayout(width=210, height=297), - page_number=1 - ) + page = Page(layout=PageLayout(width=210, height=297), page_number=1) mock_window.project.pages = [page] widget.window = Mock(return_value=mock_window) @@ -120,10 +118,7 @@ class TestViewportCalculations: mock_window.project = Project(name="Test") 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) mock_window.project.pages = [page] widget.window = Mock(return_value=mock_window) @@ -144,10 +139,7 @@ class TestViewportCalculations: mock_window.project = Project(name="Test") 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) mock_window.project.pages = [page] widget.window = Mock(return_value=mock_window) @@ -167,10 +159,7 @@ class TestViewportCalculations: mock_window.project = Project(name="Test") mock_window.project.working_dpi = 300 # High DPI - page = Page( - layout=PageLayout(width=210, height=297), - page_number=1 - ) + page = Page(layout=PageLayout(width=210, height=297), page_number=1) mock_window.project.pages = [page] widget.window = Mock(return_value=mock_window) @@ -226,10 +215,7 @@ class TestViewportCentering: mock_window.project.working_dpi = 96 # A4 page: 210mm x 297mm - page = Page( - layout=PageLayout(width=210, height=297), - page_number=1 - ) + page = Page(layout=PageLayout(width=210, height=297), page_number=1) mock_window.project.pages = [page] widget.window = Mock(return_value=mock_window) @@ -262,10 +248,7 @@ class TestViewportCentering: mock_window.project.working_dpi = 96 # A4 page: 210mm x 297mm - page = Page( - layout=PageLayout(width=210, height=297), - page_number=1 - ) + page = Page(layout=PageLayout(width=210, height=297), page_number=1) mock_window.project.pages = [page] widget.window = Mock(return_value=mock_window) @@ -298,10 +281,7 @@ class TestViewportCentering: mock_window.project.working_dpi = 96 # 6x4 inch photo: 152.4mm x 101.6mm - page = Page( - layout=PageLayout(width=152.4, height=101.6), - page_number=1 - ) + page = Page(layout=PageLayout(width=152.4, height=101.6), page_number=1) mock_window.project.pages = [page] widget.window = Mock(return_value=mock_window) @@ -324,10 +304,7 @@ class TestViewportCentering: mock_window.project.working_dpi = 96 # A4 page: 210mm x 297mm - page = Page( - layout=PageLayout(width=210, height=297), - page_number=1 - ) + page = Page(layout=PageLayout(width=210, height=297), page_number=1) mock_window.project.pages = [page] widget.window = Mock(return_value=mock_window) @@ -337,7 +314,7 @@ class TestViewportCentering: # Large window should have large positive offsets assert offset[0] > 1000 # Lots of horizontal space - assert offset[1] > 400 # Lots of vertical space + assert offset[1] > 400 # Lots of vertical space def test_calculate_center_pan_offset_different_zoom_levels(self, qtbot): """Test that different zoom levels produce different offsets""" @@ -349,10 +326,7 @@ class TestViewportCentering: mock_window.project = Project(name="Test") 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) mock_window.project.pages = [page] widget.window = Mock(return_value=mock_window) @@ -378,10 +352,7 @@ class TestViewportCentering: mock_window = Mock() 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) mock_window.project.pages = [page] # Test at 96 DPI @@ -412,10 +383,7 @@ class TestViewportResizing: mock_window.project = Project(name="Test") 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) mock_window.project.pages = [page] widget.window = Mock(return_value=mock_window) @@ -424,14 +392,14 @@ class TestViewportResizing: widget.zoom_level = 0.5 # Get initial centered offset for 1000x800 - with patch.object(widget, 'width', return_value=1000): - with patch.object(widget, 'height', return_value=800): + with patch.object(widget, "width", return_value=1000): + with patch.object(widget, "height", return_value=800): initial_offset = list(widget._calculate_center_pan_offset(0.5)) # Trigger a resize to larger window (1200x900) # Mock the widget's dimensions during resizeGL - with patch.object(widget, 'width', return_value=1200): - with patch.object(widget, 'height', return_value=900): + with patch.object(widget, "width", return_value=1200): + with patch.object(widget, "height", return_value=900): widget.resizeGL(1200, 900) new_offset = widget.pan_offset @@ -466,10 +434,7 @@ class TestViewportResizing: mock_window.project = Project(name="Test") 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) mock_window.project.pages = [page] widget.window = Mock(return_value=mock_window) @@ -493,10 +458,7 @@ class TestViewportResizing: mock_window.project = Project(name="Test") 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) mock_window.project.pages = [page] widget.window = Mock(return_value=mock_window) @@ -527,7 +489,7 @@ class TestViewportOpenGL: qtbot.addWidget(widget) # Just verify the method exists and is callable - assert hasattr(widget, 'initializeGL') + assert hasattr(widget, "initializeGL") assert callable(widget.initializeGL) def test_resizeGL_is_callable(self, qtbot): @@ -535,5 +497,5 @@ class TestViewportOpenGL: widget = TestViewportWidget() qtbot.addWidget(widget) - assert hasattr(widget, 'resizeGL') + assert hasattr(widget, "resizeGL") assert callable(widget.resizeGL) diff --git a/tests/test_zorder.py b/tests/test_zorder.py index cb0b3b9..e4cc0b4 100755 --- a/tests/test_zorder.py +++ b/tests/test_zorder.py @@ -10,35 +10,35 @@ from pyPhotoAlbum.commands import ChangeZOrderCommand, CommandHistory class TestZOrderBasics: """Tests for basic z-order functionality""" - + def test_list_order_is_render_order(self): """Test that list order determines render order""" layout = PageLayout(width=210, height=297) - + # Add elements in order elem1 = ImageData(x=10, y=10, width=50, height=50) elem2 = TextBoxData(x=20, y=20, width=50, height=50) elem3 = PlaceholderData(x=30, y=30, width=50, height=50) - + layout.add_element(elem1) layout.add_element(elem2) layout.add_element(elem3) - + # Verify order assert layout.elements[0] is elem1 assert layout.elements[1] is elem2 assert layout.elements[2] is elem3 - + def test_element_at_end_renders_on_top(self): """Test that element at end of list renders on top""" layout = PageLayout(width=210, height=297) - + elem1 = ImageData(x=10, y=10) elem2 = ImageData(x=20, y=20) - + layout.add_element(elem1) layout.add_element(elem2) - + # elem2 should be last (on top) assert layout.elements[-1] is elem2 assert layout.elements.index(elem2) > layout.elements.index(elem1) @@ -46,133 +46,133 @@ class TestZOrderBasics: class TestChangeZOrderCommand: """Tests for ChangeZOrderCommand""" - + def test_move_element_forward(self): """Test moving an element forward one position""" layout = PageLayout() elem1 = ImageData(x=10, y=10) elem2 = TextBoxData(x=20, y=20) elem3 = PlaceholderData(x=30, y=30) - + layout.add_element(elem1) layout.add_element(elem2) layout.add_element(elem3) - + # Move elem1 forward (swap with elem2) cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=1) cmd.execute() - + assert layout.elements.index(elem1) == 1 assert layout.elements.index(elem2) == 0 assert layout.elements.index(elem3) == 2 - + def test_move_element_backward(self): """Test moving an element backward one position""" layout = PageLayout() elem1 = ImageData(x=10, y=10) elem2 = TextBoxData(x=20, y=20) elem3 = PlaceholderData(x=30, y=30) - + layout.add_element(elem1) layout.add_element(elem2) layout.add_element(elem3) - + # Move elem2 backward (swap with elem1) cmd = ChangeZOrderCommand(layout, elem2, old_index=1, new_index=0) cmd.execute() - + assert layout.elements.index(elem2) == 0 assert layout.elements.index(elem1) == 1 assert layout.elements.index(elem3) == 2 - + def test_move_to_front(self): """Test moving an element to the front (end of list)""" layout = PageLayout() elem1 = ImageData(x=10, y=10) elem2 = TextBoxData(x=20, y=20) elem3 = PlaceholderData(x=30, y=30) - + layout.add_element(elem1) layout.add_element(elem2) layout.add_element(elem3) - + # Move elem1 to front cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=2) cmd.execute() - + assert layout.elements[-1] is elem1 assert layout.elements.index(elem1) == 2 - + def test_move_to_back(self): """Test moving an element to the back (start of list)""" layout = PageLayout() elem1 = ImageData(x=10, y=10) elem2 = TextBoxData(x=20, y=20) elem3 = PlaceholderData(x=30, y=30) - + layout.add_element(elem1) layout.add_element(elem2) layout.add_element(elem3) - + # Move elem3 to back cmd = ChangeZOrderCommand(layout, elem3, old_index=2, new_index=0) cmd.execute() - + assert layout.elements[0] is elem3 assert layout.elements.index(elem3) == 0 - + def test_undo_redo(self): """Test undo/redo functionality""" layout = PageLayout() elem1 = ImageData(x=10, y=10) elem2 = TextBoxData(x=20, y=20) elem3 = PlaceholderData(x=30, y=30) - + layout.add_element(elem1) layout.add_element(elem2) layout.add_element(elem3) - + original_order = list(layout.elements) - + # Move elem1 forward cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=1) cmd.execute() - + assert layout.elements.index(elem1) == 1 - + # Undo cmd.undo() assert layout.elements == original_order - + # Redo cmd.redo() assert layout.elements.index(elem1) == 1 - + def test_command_with_history(self): """Test ChangeZOrderCommand with CommandHistory""" layout = PageLayout() history = CommandHistory() - + elem1 = ImageData(x=10, y=10) elem2 = TextBoxData(x=20, y=20) elem3 = PlaceholderData(x=30, y=30) - + layout.add_element(elem1) layout.add_element(elem2) layout.add_element(elem3) - + # Execute command through history cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=2) history.execute(cmd) - + assert layout.elements.index(elem1) == 2 assert history.can_undo() - + # Undo through history history.undo() assert layout.elements.index(elem1) == 0 assert history.can_redo() - + # Redo through history history.redo() assert layout.elements.index(elem1) == 2 @@ -180,72 +180,93 @@ class TestChangeZOrderCommand: class TestZOrderSerialization: """Tests for z-order serialization and deserialization""" - + def test_serialize_preserves_order(self): """Test that serialization preserves element order""" layout = PageLayout() elem1 = ImageData(x=10, y=10, z_index=0) elem2 = TextBoxData(x=20, y=20, z_index=1) elem3 = PlaceholderData(x=30, y=30, z_index=2) - + layout.add_element(elem1) layout.add_element(elem2) layout.add_element(elem3) - + # Serialize data = layout.serialize() - + # Elements should be in order - assert len(data['elements']) == 3 - assert data['elements'][0]['type'] == 'image' - assert data['elements'][1]['type'] == 'textbox' - assert data['elements'][2]['type'] == 'placeholder' - + assert len(data["elements"]) == 3 + assert data["elements"][0]["type"] == "image" + assert data["elements"][1]["type"] == "textbox" + assert data["elements"][2]["type"] == "placeholder" + def test_deserialize_sorts_by_zindex(self): """Test that deserialization sorts by z_index for backward compatibility""" layout = PageLayout() - + # Create data with z_index values out of order data = { - 'size': (210, 297), - 'base_width': 210, - 'is_facing_page': False, - 'background_color': (1.0, 1.0, 1.0), - 'elements': [ - {'type': 'image', 'position': (10, 10), 'size': (50, 50), - 'rotation': 0, 'z_index': 2, 'image_path': '', 'crop_info': (0, 0, 1, 1)}, - {'type': 'textbox', 'position': (20, 20), 'size': (50, 50), - 'rotation': 0, 'z_index': 0, 'text_content': '', - 'font_settings': {}, 'alignment': 'left'}, - {'type': 'placeholder', 'position': (30, 30), 'size': (50, 50), - 'rotation': 0, 'z_index': 1, 'placeholder_type': 'image', 'default_content': ''}, - ] + "size": (210, 297), + "base_width": 210, + "is_facing_page": False, + "background_color": (1.0, 1.0, 1.0), + "elements": [ + { + "type": "image", + "position": (10, 10), + "size": (50, 50), + "rotation": 0, + "z_index": 2, + "image_path": "", + "crop_info": (0, 0, 1, 1), + }, + { + "type": "textbox", + "position": (20, 20), + "size": (50, 50), + "rotation": 0, + "z_index": 0, + "text_content": "", + "font_settings": {}, + "alignment": "left", + }, + { + "type": "placeholder", + "position": (30, 30), + "size": (50, 50), + "rotation": 0, + "z_index": 1, + "placeholder_type": "image", + "default_content": "", + }, + ], } - + layout.deserialize(data) - + # Elements should be sorted by z_index assert len(layout.elements) == 3 assert isinstance(layout.elements[0], TextBoxData) # z_index=0 assert isinstance(layout.elements[1], PlaceholderData) # z_index=1 assert isinstance(layout.elements[2], ImageData) # z_index=2 - + def test_roundtrip_maintains_order(self): """Test that serialize/deserialize maintains element order""" layout1 = PageLayout() elem1 = ImageData(x=10, y=10, z_index=0) elem2 = TextBoxData(x=20, y=20, z_index=1) elem3 = PlaceholderData(x=30, y=30, z_index=2) - + layout1.add_element(elem1) layout1.add_element(elem2) layout1.add_element(elem3) - + # Serialize and deserialize data = layout1.serialize() layout2 = PageLayout() layout2.deserialize(data) - + # Order should be maintained assert len(layout2.elements) == 3 assert isinstance(layout2.elements[0], ImageData) @@ -255,126 +276,126 @@ class TestZOrderSerialization: class TestZOrderEdgeCases: """Tests for z-order edge cases""" - + def test_single_element(self): """Test operations with single element""" layout = PageLayout() elem = ImageData(x=10, y=10) layout.add_element(elem) - + # Try to move forward (should stay at index 0) cmd = ChangeZOrderCommand(layout, elem, old_index=0, new_index=0) cmd.execute() - + assert layout.elements.index(elem) == 0 - + def test_empty_list(self): """Test operations with empty list""" layout = PageLayout() assert len(layout.elements) == 0 - + def test_move_to_same_position(self): """Test moving element to its current position""" layout = PageLayout() elem1 = ImageData(x=10, y=10) elem2 = TextBoxData(x=20, y=20) - + layout.add_element(elem1) layout.add_element(elem2) - + # Move to same position cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=0) cmd.execute() - + assert layout.elements.index(elem1) == 0 assert layout.elements.index(elem2) == 1 - + def test_swap_adjacent_elements(self): """Test swapping two adjacent elements""" layout = PageLayout() elem1 = ImageData(x=10, y=10) elem2 = TextBoxData(x=20, y=20) - + layout.add_element(elem1) layout.add_element(elem2) - + # Swap by moving elem1 forward elements = layout.elements index1 = elements.index(elem1) index2 = elements.index(elem2) elements[index1], elements[index2] = elements[index2], elements[index1] - + assert layout.elements[0] is elem2 assert layout.elements[1] is elem1 - + def test_multiple_zorder_changes(self): """Test multiple z-order changes in sequence""" layout = PageLayout() history = CommandHistory() - + elem1 = ImageData(x=10, y=10) elem2 = TextBoxData(x=20, y=20) elem3 = PlaceholderData(x=30, y=30) - + layout.add_element(elem1) layout.add_element(elem2) layout.add_element(elem3) - + # Move elem1 to front cmd1 = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=2) history.execute(cmd1) assert layout.elements.index(elem1) == 2 - + # Move elem2 to front cmd2 = ChangeZOrderCommand(layout, elem2, old_index=0, new_index=2) history.execute(cmd2) assert layout.elements.index(elem2) == 2 - + # Undo both history.undo() assert layout.elements.index(elem2) == 0 - + history.undo() assert layout.elements.index(elem1) == 0 class TestZOrderCommandSerialization: """Tests for ChangeZOrderCommand serialization""" - + def test_serialize_command(self): """Test serializing a ChangeZOrderCommand""" layout = PageLayout() elem = ImageData(x=10, y=10) layout.add_element(elem) - + cmd = ChangeZOrderCommand(layout, elem, old_index=0, new_index=1) - + data = cmd.serialize() - - assert data['type'] == 'change_zorder' - assert data['old_index'] == 0 - assert data['new_index'] == 1 - assert 'element' in data - + + assert data["type"] == "change_zorder" + assert data["old_index"] == 0 + assert data["new_index"] == 1 + assert "element" in data + def test_deserialize_command(self): """Test deserializing a ChangeZOrderCommand""" data = { - 'type': 'change_zorder', - 'element': { - 'type': 'image', - 'position': (10, 10), - 'size': (50, 50), - 'rotation': 0, - 'z_index': 0, - 'image_path': '', - 'crop_info': (0, 0, 1, 1) + "type": "change_zorder", + "element": { + "type": "image", + "position": (10, 10), + "size": (50, 50), + "rotation": 0, + "z_index": 0, + "image_path": "", + "crop_info": (0, 0, 1, 1), }, - 'old_index': 0, - 'new_index': 1 + "old_index": 0, + "new_index": 1, } - + cmd = ChangeZOrderCommand.deserialize(data, None) - + assert isinstance(cmd, ChangeZOrderCommand) assert cmd.old_index == 0 assert cmd.new_index == 1 diff --git a/tests/test_zorder_ops_mixin.py b/tests/test_zorder_ops_mixin.py index 9e71505..8b99950 100755 --- a/tests/test_zorder_ops_mixin.py +++ b/tests/test_zorder_ops_mixin.py @@ -34,7 +34,7 @@ class TestZOrderWindow(ZOrderOperationsMixin, QMainWindow): def get_current_page(self): """Return mock current page""" - if hasattr(self, '_current_page'): + if hasattr(self, "_current_page"): return self._current_page return None