diff --git a/pyPhotoAlbum/async_backend.py b/pyPhotoAlbum/async_backend.py index ca0f46b..9790b4e 100644 --- a/pyPhotoAlbum/async_backend.py +++ b/pyPhotoAlbum/async_backend.py @@ -14,7 +14,8 @@ from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from enum import Enum from pathlib import Path -from typing import Optional, Callable, Dict, Any, Tuple +from typing import Optional, Callable, Dict, Any, Tuple, Union +from concurrent.futures import Future from collections import OrderedDict import threading @@ -230,7 +231,7 @@ class AsyncImageLoader(QObject): 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 + self._queue: Optional[asyncio.PriorityQueue[Any]] = None # Created when event loop starts self._pending_requests: Dict[Path, LoadRequest] = {} self._active_tasks: Dict[Path, asyncio.Task] = {} self._next_request_id = 0 @@ -373,7 +374,7 @@ class AsyncImageLoader(QObject): self._pending_requests.pop(path, None) self._active_tasks.pop(path, None) - def _load_and_process_image(self, path: Path, target_size: Optional[Tuple[int, int]]) -> Image.Image: + def _load_and_process_image(self, path: Path, target_size: Optional[Tuple[int, int]]) -> "Image.Image": """ Load image from disk and process (runs in thread pool). @@ -528,7 +529,7 @@ class AsyncPDFGenerator(QObject): self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="PDFGenerator") # Export state - self._current_export: Optional[asyncio.Task] = None + self._current_export: Optional[Future[Any]] = None self._cancel_requested = False self._lock = threading.Lock() self._shutdown = False @@ -675,7 +676,7 @@ class AsyncPDFGenerator(QObject): with self._lock: self._current_export = None - def _export_with_cache(self, exporter, output_path: str, progress_callback) -> Tuple[bool, list]: + def _export_with_cache(self, exporter: Any, output_path: str, progress_callback: Any) -> Tuple[bool, list[Any]]: """ Run PDF export with image cache integration. diff --git a/pyPhotoAlbum/autosave_manager.py b/pyPhotoAlbum/autosave_manager.py index 827779b..fc255e3 100644 --- a/pyPhotoAlbum/autosave_manager.py +++ b/pyPhotoAlbum/autosave_manager.py @@ -10,7 +10,7 @@ import json import shutil from pathlib import Path from datetime import datetime, timedelta -from typing import Optional, List, Tuple +from typing import Dict, Optional, List, Tuple from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip @@ -186,7 +186,7 @@ class AutosaveManager: max_count: Maximum number of checkpoints to keep per project """ now = datetime.now() - checkpoints_by_project = {} + checkpoints_by_project: Dict[str, List[Tuple[Path, dict]]] = {} # Group checkpoints by project for checkpoint_path, metadata in self.list_checkpoints(): diff --git a/pyPhotoAlbum/commands.py b/pyPhotoAlbum/commands.py index 9079e62..cff4fa1 100644 --- a/pyPhotoAlbum/commands.py +++ b/pyPhotoAlbum/commands.py @@ -98,6 +98,7 @@ class AddElementCommand(Command): elem_data = data["element"] elem_type = elem_data.get("type") + element: BaseLayoutElement if elem_type == "image": element = ImageData() elif elem_type == "placeholder": @@ -156,6 +157,7 @@ class DeleteElementCommand(Command): elem_data = data["element"] elem_type = elem_data.get("type") + element: BaseLayoutElement if elem_type == "image": element = ImageData() elif elem_type == "placeholder": @@ -207,6 +209,7 @@ class MoveElementCommand(Command): elem_data = data["element"] elem_type = elem_data.get("type") + element: BaseLayoutElement if elem_type == "image": element = ImageData() elif elem_type == "placeholder": @@ -264,6 +267,7 @@ class ResizeElementCommand(Command): elem_data = data["element"] elem_type = elem_data.get("type") + element: BaseLayoutElement if elem_type == "image": element = ImageData() elif elem_type == "placeholder": @@ -388,6 +392,7 @@ class RotateElementCommand(Command): elem_data = data["element"] elem_type = elem_data.get("type") + element: BaseLayoutElement if elem_type == "image": element = ImageData() elif elem_type == "placeholder": @@ -485,6 +490,7 @@ class AlignElementsCommand(Command): elem_data = change_data["element"] elem_type = elem_data.get("type") + element: BaseLayoutElement if elem_type == "image": element = ImageData() elif elem_type == "placeholder": @@ -546,6 +552,7 @@ class ResizeElementsCommand(Command): elem_data = change_data["element"] elem_type = elem_data.get("type") + element: BaseLayoutElement if elem_type == "image": element = ImageData() elif elem_type == "placeholder": @@ -605,6 +612,7 @@ class ChangeZOrderCommand(Command): elem_data = data["element"] elem_type = elem_data.get("type") + element: BaseLayoutElement if elem_type == "image": element = ImageData() elif elem_type == "placeholder": diff --git a/pyPhotoAlbum/decorators.py b/pyPhotoAlbum/decorators.py index e378c74..0f2e415 100644 --- a/pyPhotoAlbum/decorators.py +++ b/pyPhotoAlbum/decorators.py @@ -4,7 +4,7 @@ Decorator system for pyPhotoAlbum ribbon UI import copy from functools import wraps -from typing import Optional, Callable +from typing import Any, Optional, Callable class RibbonAction: @@ -75,11 +75,11 @@ class RibbonAction: """ @wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args: Any, **kwargs: Any) -> Any: return func(*args, **kwargs) # Store metadata on wrapper function - wrapper._ribbon_action = { + wrapper._ribbon_action = { # type: ignore[attr-defined] "label": self.label, "tooltip": self.tooltip, "tab": self.tab, @@ -178,11 +178,11 @@ class NumericalInput: """ @wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args: Any, **kwargs: Any) -> Any: return func(*args, **kwargs) # Store metadata on wrapper function - wrapper._numerical_input = {"fields": self.fields} + wrapper._numerical_input = {"fields": self.fields} # type: ignore[attr-defined] return wrapper @@ -218,7 +218,7 @@ class UndoableOperation: # Decorator handles undo/redo automatically """ - def __init__(self, capture: str = "page_elements", description: str = None): + def __init__(self, capture: str = "page_elements", description: Optional[str] = None): """ Initialize the undoable operation decorator. @@ -298,10 +298,11 @@ class UndoableOperation: current_page.layout.elements.clear() # Restore elements from serialized state - from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData + from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData for elem_data in state: elem_type = elem_data.get("type") + elem: BaseLayoutElement if elem_type == "image": elem = ImageData() elif elem_type == "placeholder": @@ -315,7 +316,7 @@ class UndoableOperation: current_page.layout.add_element(elem) -def undoable_operation(capture: str = "page_elements", description: str = None) -> Callable: +def undoable_operation(capture: str = "page_elements", description: Optional[str] = None) -> Callable: """ Convenience function for the UndoableOperation decorator. diff --git a/pyPhotoAlbum/image_utils.py b/pyPhotoAlbum/image_utils.py index df6a75f..85481ea 100644 --- a/pyPhotoAlbum/image_utils.py +++ b/pyPhotoAlbum/image_utils.py @@ -30,11 +30,11 @@ def apply_pil_rotation(image: Image.Image, pil_rotation_90: int) -> Image.Image: angle = pil_rotation_90 * 90 if angle == 90: - return image.transpose(Image.ROTATE_270) # CCW 90 = rotate right + return image.transpose(Image.Transpose.ROTATE_270) # CCW 90 = rotate right elif angle == 180: - return image.transpose(Image.ROTATE_180) + return image.transpose(Image.Transpose.ROTATE_180) elif angle == 270: - return image.transpose(Image.ROTATE_90) # CCW 270 = rotate left + return image.transpose(Image.Transpose.ROTATE_90) # CCW 270 = rotate left return image diff --git a/pyPhotoAlbum/loading_widget.py b/pyPhotoAlbum/loading_widget.py index e6f211a..9de1442 100644 --- a/pyPhotoAlbum/loading_widget.py +++ b/pyPhotoAlbum/loading_widget.py @@ -5,7 +5,7 @@ Displays loading progress in the lower-right corner of the window. """ from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar -from PyQt6.QtCore import Qt, QPropertyAnimation, QEasingCurve, pyqtProperty +from PyQt6.QtCore import Qt, QPropertyAnimation, QEasingCurve, pyqtProperty # type: ignore[attr-defined] from PyQt6.QtGui import QPalette, QColor @@ -98,8 +98,8 @@ class LoadingWidget(QWidget): """Get opacity for animation""" return self._opacity - @opacity.setter - def opacity(self, value): + @opacity.setter # type: ignore[no-redef] + def opacity(self, value: float) -> None: """Set opacity for animation""" self._opacity = value self.setWindowOpacity(value) diff --git a/pyPhotoAlbum/merge_manager.py b/pyPhotoAlbum/merge_manager.py index ec5b617..536afcb 100644 --- a/pyPhotoAlbum/merge_manager.py +++ b/pyPhotoAlbum/merge_manager.py @@ -79,7 +79,7 @@ class MergeManager: print("MergeManager: One or both projects lack project_id, assuming concatenation") return False - return project_a_id == project_b_id + return bool(project_a_id == project_b_id) def detect_conflicts( self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any] @@ -303,7 +303,7 @@ class MergeManager: # 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[int, str]: """ Automatically resolve conflicts based on a strategy. diff --git a/pyPhotoAlbum/mixins/async_loading.py b/pyPhotoAlbum/mixins/async_loading.py index abfa696..8f328fb 100644 --- a/pyPhotoAlbum/mixins/async_loading.py +++ b/pyPhotoAlbum/mixins/async_loading.py @@ -4,17 +4,31 @@ Async loading mixin for non-blocking image loading and PDF generation. import os from pathlib import Path -from typing import Optional +from typing import TYPE_CHECKING, Optional import logging from PyQt6.QtCore import QObject +from PyQt6.QtWidgets import QProgressDialog from pyPhotoAlbum.async_backend import AsyncImageLoader, AsyncPDFGenerator, ImageCache, LoadPriority +if TYPE_CHECKING: + from PyQt6.QtWidgets import QMainWindow + logger = logging.getLogger(__name__) class AsyncLoadingMixin: + # Type hints for expected attributes from mixing class + _pdf_progress_dialog: Optional[QProgressDialog] + + def update(self) -> None: + """Expected from QWidget""" + ... + + def window(self) -> "QMainWindow": + """Expected from QWidget""" + ... """ Mixin to add async loading capabilities to GLWidget. diff --git a/pyPhotoAlbum/mixins/base.py b/pyPhotoAlbum/mixins/base.py index 1b044c0..f551853 100644 --- a/pyPhotoAlbum/mixins/base.py +++ b/pyPhotoAlbum/mixins/base.py @@ -2,8 +2,8 @@ Base mixin providing shared application state access """ -from typing import Optional -from PyQt6.QtWidgets import QStatusBar, QMessageBox +from typing import Any, Optional, cast +from PyQt6.QtWidgets import QStatusBar, QMessageBox, QWidget class ApplicationStateMixin: @@ -44,7 +44,7 @@ class ApplicationStateMixin: """Access to status bar""" if not hasattr(self, "_status_bar"): raise AttributeError("MainWindow must set _status_bar attribute") - return self._status_bar + return cast(QStatusBar, self._status_bar) @property def template_manager(self): @@ -117,7 +117,7 @@ class ApplicationStateMixin: """ if not self.project or not self.project.pages: return -1 - return self.gl_widget.current_page_index + return int(self.gl_widget.current_page_index) def show_status(self, message: str, timeout: int = 2000): """ @@ -138,7 +138,7 @@ class ApplicationStateMixin: title: Dialog title message: Error message """ - QMessageBox.critical(self, title, message) + QMessageBox.critical(cast(QWidget, self), title, message) def show_warning(self, title: str, message: str): """ @@ -148,7 +148,7 @@ class ApplicationStateMixin: title: Dialog title message: Warning message """ - QMessageBox.warning(self, title, message) + QMessageBox.warning(cast(QWidget, self), title, message) def show_info(self, title: str, message: str): """ @@ -158,7 +158,7 @@ class ApplicationStateMixin: title: Dialog title message: Information message """ - QMessageBox.information(self, title, message) + QMessageBox.information(cast(QWidget, self), title, message) def require_page(self, show_warning: bool = True) -> bool: """ diff --git a/pyPhotoAlbum/mixins/element_manipulation.py b/pyPhotoAlbum/mixins/element_manipulation.py index 6cde1e7..a169341 100644 --- a/pyPhotoAlbum/mixins/element_manipulation.py +++ b/pyPhotoAlbum/mixins/element_manipulation.py @@ -2,18 +2,31 @@ Element manipulation mixin for GLWidget - handles element transformations """ -from typing import Optional, Tuple +from typing import TYPE_CHECKING, Any, Optional, Tuple + +if TYPE_CHECKING: + from pyPhotoAlbum.models import BaseLayoutElement + from PyQt6.QtWidgets import QMainWindow class ElementManipulationMixin: - """ - Mixin providing element transformation functionality. + # Type hints for expected attributes from mixing class + selected_element: Optional["BaseLayoutElement"] + drag_start_pos: Optional[Tuple[float, float]] + drag_start_element_pos: Optional[Tuple[float, float]] - This mixin handles resizing, rotating, and moving elements, including - snapping support and cross-page element transfers. - """ + def window(self) -> "QMainWindow": + """Expected from QWidget""" + ... def __init__(self, *args, **kwargs): + """ + Initialize element manipulation mixin. + + This mixin provides element transformation functionality including + resizing, rotating, moving elements, snapping support and cross-page + element transfers. + """ super().__init__(*args, **kwargs) # Resize state diff --git a/pyPhotoAlbum/mixins/element_selection.py b/pyPhotoAlbum/mixins/element_selection.py index 8f671c3..f0cec68 100644 --- a/pyPhotoAlbum/mixins/element_selection.py +++ b/pyPhotoAlbum/mixins/element_selection.py @@ -2,11 +2,21 @@ Element selection mixin for GLWidget - handles element selection and hit detection """ -from typing import Optional, Set +from typing import Any, TYPE_CHECKING, Optional, Set + +if TYPE_CHECKING: + from PyQt6.QtWidgets import QMainWindow + from pyPhotoAlbum.models import BaseLayoutElement class ElementSelectionMixin: + # Type hints for expected attributes from mixing class + _page_renderers: list + + def window(self) -> "QMainWindow": + """Expected from QWidget""" + ... """ Mixin providing element selection and hit detection functionality. @@ -73,8 +83,8 @@ class ElementSelectionMixin: # Simple bounds check (no rotation transformation needed - images are already rotated) if ex <= page_x <= ex + ew and ey <= page_y <= ey + eh: # Store the renderer with the element for later use - element._page_renderer = renderer - element._parent_page = page + element._page_renderer = renderer # type: ignore[attr-defined] + element._parent_page = page # type: ignore[attr-defined] return element return None @@ -103,7 +113,7 @@ class ElementSelectionMixin: if not hasattr(self.selected_element, "_page_renderer"): return None - renderer = self.selected_element._page_renderer + renderer: Any = self.selected_element._page_renderer # type: ignore[attr-defined] # Get element position and size in page-local coordinates elem_x, elem_y = self.selected_element.position diff --git a/pyPhotoAlbum/mixins/image_pan.py b/pyPhotoAlbum/mixins/image_pan.py index 61f852a..39fb256 100644 --- a/pyPhotoAlbum/mixins/image_pan.py +++ b/pyPhotoAlbum/mixins/image_pan.py @@ -2,11 +2,16 @@ Image pan mixin for GLWidget - handles panning images within frames """ -from typing import Optional, Tuple +from typing import TYPE_CHECKING, Optional, Tuple + + from pyPhotoAlbum.models import ImageData class ImagePanMixin: + # Type hints for expected attributes from mixing class + drag_start_pos: Optional[Tuple[float, float]] + zoom_level: float """ Mixin providing image panning functionality. @@ -21,7 +26,7 @@ class ImagePanMixin: self.image_pan_mode: bool = False # True when Control+dragging an ImageData element self.image_pan_start_crop: Optional[Tuple[float, float, float, float]] = None # Starting crop_info - def _handle_image_pan_move(self, x: float, y: float, element: ImageData): + def _handle_image_pan_move(self, x: float, y: float, element: "ImageData"): """ Handle image panning within a frame during mouse move. diff --git a/pyPhotoAlbum/mixins/operations/file_ops.py b/pyPhotoAlbum/mixins/operations/file_ops.py index 7918558..3e36651 100644 --- a/pyPhotoAlbum/mixins/operations/file_ops.py +++ b/pyPhotoAlbum/mixins/operations/file_ops.py @@ -3,6 +3,7 @@ File operations mixin for pyPhotoAlbum """ import os +from typing import TYPE_CHECKING, Optional, cast from PyQt6.QtWidgets import ( QFileDialog, @@ -18,6 +19,8 @@ from PyQt6.QtWidgets import ( QButtonGroup, QLineEdit, QTextEdit, + QWidget, + QMessageBox, ) from pyPhotoAlbum.decorators import ribbon_action, numerical_input from pyPhotoAlbum.project import Project, Page @@ -32,6 +35,19 @@ from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog class FileOperationsMixin: """Mixin providing file-related operations""" + # Type hints for expected attributes from mixing class + def show_status(self, message: str, timeout: int = 0) -> None: + """Expected from ApplicationStateMixin""" + ... + + def show_error(self, title: str, message: str) -> None: + """Expected from ApplicationStateMixin""" + ... + + def resolve_asset_path(self, path: str) -> Optional[str]: + """Expected from asset path mixin""" + ... + @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""" @@ -324,7 +340,7 @@ class FileOperationsMixin: asset_list = "\n".join(f" • {path}" for path in missing_assets[:5]) asset_list += f"\n ... and {len(missing_assets) - 5} more" - msg = QMessageBox(self) + msg = QMessageBox(cast(QWidget, self)) msg.setIcon(QMessageBox.Icon.Warning) msg.setWindowTitle("Missing Assets") msg.setText(f"{len(missing_assets)} image(s) could not be found in the assets folder:") diff --git a/pyPhotoAlbum/mixins/page_navigation.py b/pyPhotoAlbum/mixins/page_navigation.py index 5e10d44..8198fae 100644 --- a/pyPhotoAlbum/mixins/page_navigation.py +++ b/pyPhotoAlbum/mixins/page_navigation.py @@ -2,10 +2,24 @@ Page navigation mixin for GLWidget - handles page detection and ghost pages """ -from typing import Optional, Tuple, List +from typing import TYPE_CHECKING, Optional, Tuple, List + +if TYPE_CHECKING: + from PyQt6.QtWidgets import QMainWindow class PageNavigationMixin: + # Type hints for expected attributes from mixing class + pan_offset: Tuple[float, float] + zoom_level: float + + def update(self) -> None: + """Expected from QWidget""" + ... + + def window(self) -> "QMainWindow": + """Expected from QWidget""" + ... """ Mixin providing page navigation and ghost page functionality. diff --git a/pyPhotoAlbum/page_layout.py b/pyPhotoAlbum/page_layout.py index a812f77..5b6d4ce 100644 --- a/pyPhotoAlbum/page_layout.py +++ b/pyPhotoAlbum/page_layout.py @@ -2,7 +2,11 @@ Page layout and template system for pyPhotoAlbum """ -from typing import List, Dict, Any, Optional, Tuple +from typing import List, Dict, Any, Optional, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from PyQt6.QtWidgets import QWidget + from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData from pyPhotoAlbum.snapping import SnappingSystem from pyPhotoAlbum.gl_imports import ( @@ -45,6 +49,7 @@ class PageLayout: self.background_color = (1.0, 1.0, 1.0) # White background self.snapping_system = SnappingSystem() self.show_snap_lines = True # Show snap lines while dragging + self._parent_widget: Optional["QWidget"] = None # Set by renderer def add_element(self, element: BaseLayoutElement): """Add a layout element to the page""" @@ -251,9 +256,10 @@ class PageLayout: # Deserialize elements and sort by z_index to establish list order # This ensures backward compatibility with projects that used z_index - elem_list = [] + elem_list: List[BaseLayoutElement] = [] for elem_data in data.get("elements", []): elem_type = elem_data.get("type") + elem: BaseLayoutElement if elem_type == "image": elem = ImageData() elif elem_type == "placeholder": diff --git a/pyPhotoAlbum/pdf_exporter.py b/pyPhotoAlbum/pdf_exporter.py index c8f9c4f..3fc4e78 100644 --- a/pyPhotoAlbum/pdf_exporter.py +++ b/pyPhotoAlbum/pdf_exporter.py @@ -3,7 +3,7 @@ PDF export functionality for pyPhotoAlbum """ import os -from typing import List, Tuple, Optional +from typing import Any, List, Tuple, Optional, Union from dataclasses import dataclass from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas @@ -44,7 +44,7 @@ class SplitRenderParams: """Parameters for rendering a split element""" canvas: canvas.Canvas - element: any + element: Any x_offset_mm: float split_line_mm: float page_width_pt: float @@ -71,7 +71,7 @@ class PDFExporter: """ self.project = project self.export_dpi = export_dpi - self.warnings = [] + self.warnings: List[str] = [] self.current_pdf_page = 1 def export(self, output_path: str, progress_callback=None) -> Tuple[bool, List[str]]: @@ -293,7 +293,7 @@ class PDFExporter: x_offset_mm: float, page_width_pt: float, page_height_pt: float, - page_number: int, + page_number: Union[int, str], ): """ Render a single element on the PDF canvas. diff --git a/pyPhotoAlbum/project.py b/pyPhotoAlbum/project.py index 719479b..9808cd3 100644 --- a/pyPhotoAlbum/project.py +++ b/pyPhotoAlbum/project.py @@ -6,7 +6,8 @@ import os import math import uuid from datetime import datetime, timezone -from typing import List, Dict, Any, Optional, Tuple +from tempfile import TemporaryDirectory +from typing import List, Dict, Any, Optional, Tuple, Union from pyPhotoAlbum.page_layout import PageLayout from pyPhotoAlbum.commands import CommandHistory from pyPhotoAlbum.asset_manager import AssetManager @@ -170,7 +171,7 @@ class Project: # Temporary directory management (if loaded from .ppz) # Using TemporaryDirectory instance that auto-cleans on deletion - self._temp_dir = None + self._temp_dir: Optional[TemporaryDirectory[str]] = None # Global snapping settings (apply to all pages) self.snap_to_grid = False @@ -323,7 +324,7 @@ class Project: else: return f"Page {adjusted_num}" - def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Any, int]]: + def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Optional["Page"], int]]: """ Calculate page layout including ghost pages for alignment. Excludes cover from spread calculations. diff --git a/pyPhotoAlbum/ribbon_builder.py b/pyPhotoAlbum/ribbon_builder.py index a7b1900..0400667 100644 --- a/pyPhotoAlbum/ribbon_builder.py +++ b/pyPhotoAlbum/ribbon_builder.py @@ -41,7 +41,7 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]: } """ # Structure to collect actions by tab and group - tabs = defaultdict(lambda: defaultdict(list)) + tabs: Dict[str, Dict[str, List[Dict[str, Any]]]] = defaultdict(lambda: defaultdict(list)) # Scan all methods in the class and its bases (mixins) for attr_name in dir(window_class): diff --git a/pyPhotoAlbum/snapping.py b/pyPhotoAlbum/snapping.py index 6ea762d..d45a8e7 100644 --- a/pyPhotoAlbum/snapping.py +++ b/pyPhotoAlbum/snapping.py @@ -4,7 +4,7 @@ Provides grid snapping, edge snapping, and custom guide snapping """ import math -from typing import List, Tuple, Optional +from typing import Any, Dict, List, Tuple, Optional from dataclasses import dataclass @@ -36,7 +36,7 @@ class SnapResizeParams: resize_handle: str page_size: Tuple[float, float] dpi: int = 300 - project: Optional[any] = None + project: Optional[Any] = None class SnappingSystem: @@ -333,12 +333,12 @@ class SnappingSystem: snap_to_guides = self.snap_to_guides grid_size_mm = self.grid_size_mm - snap_candidates = [] + snap_candidates: List[Tuple[float, float]] = [] # 1. Page edge snapping if snap_to_edges: # Snap to start edge (0) - snap_candidates.append((0, abs(edge_position - 0))) + snap_candidates.append((0.0, abs(edge_position - 0))) # Snap to end edge page_size_px = page_size_mm * dpi / 25.4 @@ -387,12 +387,12 @@ class SnappingSystem: Returns: Snapped position in pixels """ - snap_candidates = [] + snap_candidates: List[Tuple[float, float]] = [] # 1. Page edge snapping if self.snap_to_edges: # Snap to start edge (0) - snap_candidates.append((0, abs(position - 0))) + snap_candidates.append((0.0, abs(position - 0))) # Snap to end edge page_size_px = page_size_mm * dpi / 25.4 @@ -453,20 +453,20 @@ class SnappingSystem: page_width_px = page_width_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4 - result = {"grid": [], "edges": [], "guides": []} + result: Dict[str, List[Tuple[str, float]]] = {"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 + x: float = 0 while x <= page_width_px: result["grid"].append(("vertical", x)) x += grid_size_px # Horizontal grid lines - y = 0 + y: float = 0 while y <= page_height_px: result["grid"].append(("horizontal", y)) y += grid_size_px diff --git a/pyPhotoAlbum/template_manager.py b/pyPhotoAlbum/template_manager.py index c109fc2..f659efc 100644 --- a/pyPhotoAlbum/template_manager.py +++ b/pyPhotoAlbum/template_manager.py @@ -47,6 +47,7 @@ class Template: # Deserialize elements for elem_data in data.get("elements", []): elem_type = elem_data.get("type") + elem: BaseLayoutElement if elem_type == "placeholder": elem = PlaceholderData() elif elem_type == "textbox": @@ -316,9 +317,10 @@ class TemplateManager: offset_x = (to_width - from_width) / 2 offset_y = (to_height - from_height) / 2 - scaled_elements = [] + scaled_elements: List[BaseLayoutElement] = [] for element in elements: # Create a new element of the same type + new_elem: BaseLayoutElement if isinstance(element, PlaceholderData): new_elem = PlaceholderData( placeholder_type=element.placeholder_type, default_content=element.default_content diff --git a/tests/test_text_edit_dialog.py b/tests/test_text_edit_dialog.py new file mode 100644 index 0000000..febf5f4 --- /dev/null +++ b/tests/test_text_edit_dialog.py @@ -0,0 +1,414 @@ +""" +Tests for text_edit_dialog module +""" + +import pytest +from unittest.mock import Mock, patch +from PyQt6.QtGui import QColor + + +class TestTextEditDialogInit: + """Tests for TextEditDialog initialization""" + + def test_init_basic(self, qtbot): + """Test basic dialog initialization""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "Hello World" + mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + assert dialog.text_element == mock_text_element + assert dialog.windowTitle() == "Edit Text" + + def test_init_loads_text_content(self, qtbot): + """Test that init loads text content from element""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "Test content here" + mock_text_element.font_settings = {"family": "Arial", "size": 14, "color": (0, 0, 0)} + mock_text_element.alignment = "center" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + assert dialog.text_edit.toPlainText() == "Test content here" + + def test_init_loads_font_family(self, qtbot): + """Test that init loads font family from element""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {"family": "Georgia", "size": 12, "color": (0, 0, 0)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + assert dialog.font_combo.currentText() == "Georgia" + + def test_init_loads_font_size(self, qtbot): + """Test that init loads font size from element""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {"family": "Arial", "size": 24, "color": (0, 0, 0)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + assert dialog.font_size_spin.value() == 24 + + def test_init_loads_color_255_range(self, qtbot): + """Test that init loads color in 0-255 range""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (255, 128, 64)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + assert dialog.current_color.red() == 255 + assert dialog.current_color.green() == 128 + assert dialog.current_color.blue() == 64 + + def test_init_loads_color_01_range(self, qtbot): + """Test that init loads color in 0-1 range""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (1.0, 0.5, 0.0)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + assert dialog.current_color.red() == 255 + assert dialog.current_color.green() == 127 + assert dialog.current_color.blue() == 0 + + def test_init_loads_alignment(self, qtbot): + """Test that init loads alignment from element""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)} + mock_text_element.alignment = "right" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + assert dialog.alignment_combo.currentText() == "right" + + def test_init_handles_unknown_font(self, qtbot): + """Test that init handles unknown font family gracefully""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {"family": "UnknownFont", "size": 12, "color": (0, 0, 0)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + # Should not crash, will keep default selection + assert dialog.font_combo.currentIndex() >= 0 + + def test_init_handles_default_values(self, qtbot): + """Test that init handles missing font settings""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {} # Empty settings + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + # Should use defaults + assert dialog.font_size_spin.value() == 12 + + +class TestTextEditDialogUI: + """Tests for TextEditDialog UI elements""" + + def test_font_size_range(self, qtbot): + """Test that font size spinner has correct range""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + assert dialog.font_size_spin.minimum() == 6 + assert dialog.font_size_spin.maximum() == 72 + + def test_alignment_options(self, qtbot): + """Test that alignment combo has correct options""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + options = [dialog.alignment_combo.itemText(i) for i in range(dialog.alignment_combo.count())] + assert "left" in options + assert "center" in options + assert "right" in options + + def test_font_options(self, qtbot): + """Test that font combo has expected fonts""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + fonts = [dialog.font_combo.itemText(i) for i in range(dialog.font_combo.count())] + assert "Arial" in fonts + assert "Times New Roman" in fonts + assert "Courier New" in fonts + + +class TestChooseColor: + """Tests for _choose_color method""" + + def test_choose_color_updates_current_color(self, qtbot): + """Test that choosing color updates current_color""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + # Mock QColorDialog to return a specific color + with patch("pyPhotoAlbum.text_edit_dialog.QColorDialog.getColor") as mock_get_color: + mock_color = QColor(255, 0, 0) + mock_get_color.return_value = mock_color + + dialog._choose_color() + + assert dialog.current_color.red() == 255 + assert dialog.current_color.green() == 0 + assert dialog.current_color.blue() == 0 + + def test_choose_color_invalid_does_not_update(self, qtbot): + """Test that invalid color choice does not update current_color""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + original_color = QColor(dialog.current_color) + + # Mock QColorDialog to return invalid color (user cancelled) + with patch("pyPhotoAlbum.text_edit_dialog.QColorDialog.getColor") as mock_get_color: + mock_get_color.return_value = QColor() # Invalid color + + dialog._choose_color() + + # Color should remain unchanged + assert dialog.current_color == original_color + + +class TestUpdateColorButton: + """Tests for _update_color_button method""" + + def test_update_color_button_dark_color(self, qtbot): + """Test color button with dark color uses white text""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + dialog.current_color = QColor(0, 0, 0) # Black + dialog._update_color_button() + + stylesheet = dialog.color_button.styleSheet() + assert "color: white" in stylesheet + + def test_update_color_button_light_color(self, qtbot): + """Test color button with light color uses black text""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (255, 255, 255)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + dialog.current_color = QColor(255, 255, 255) # White + dialog._update_color_button() + + stylesheet = dialog.color_button.styleSheet() + assert "color: black" in stylesheet + + +class TestGetValues: + """Tests for get_values method""" + + def test_get_values_returns_text(self, qtbot): + """Test get_values returns text content""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "Original" + mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + dialog.text_edit.setPlainText("New text content") + + values = dialog.get_values() + assert values["text_content"] == "New text content" + + def test_get_values_returns_font_settings(self, qtbot): + """Test get_values returns font settings""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + dialog.font_combo.setCurrentText("Georgia") + dialog.font_size_spin.setValue(18) + dialog.current_color = QColor(100, 150, 200) + + values = dialog.get_values() + assert values["font_settings"]["family"] == "Georgia" + assert values["font_settings"]["size"] == 18 + assert values["font_settings"]["color"] == (100, 150, 200) + + def test_get_values_returns_alignment(self, qtbot): + """Test get_values returns alignment""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + dialog.alignment_combo.setCurrentText("center") + + values = dialog.get_values() + assert values["alignment"] == "center" + + def test_get_values_complete_structure(self, qtbot): + """Test get_values returns complete structure""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + + mock_text_element = Mock() + mock_text_element.text_content = "Test" + mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + values = dialog.get_values() + + # Check structure + assert "text_content" in values + assert "font_settings" in values + assert "alignment" in values + assert "family" in values["font_settings"] + assert "size" in values["font_settings"] + assert "color" in values["font_settings"] + + +class TestDialogButtons: + """Tests for dialog buttons""" + + def test_cancel_button_rejects(self, qtbot): + """Test that cancel button rejects the dialog""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + from PyQt6.QtWidgets import QPushButton + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + # Find cancel button + buttons = dialog.findChildren(QPushButton) + cancel_button = next(b for b in buttons if b.text() == "Cancel") + + # Click should reject + with qtbot.waitSignal(dialog.rejected, timeout=1000): + cancel_button.click() + + def test_ok_button_accepts(self, qtbot): + """Test that OK button accepts the dialog""" + from pyPhotoAlbum.text_edit_dialog import TextEditDialog + from PyQt6.QtWidgets import QPushButton + + mock_text_element = Mock() + mock_text_element.text_content = "" + mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)} + mock_text_element.alignment = "left" + + dialog = TextEditDialog(mock_text_element) + qtbot.addWidget(dialog) + + # Find OK button + buttons = dialog.findChildren(QPushButton) + ok_button = next(b for b in buttons if b.text() == "OK") + + # Click should accept + with qtbot.waitSignal(dialog.accepted, timeout=1000): + ok_button.click()