Coverage for pyPhotoAlbum/mixins/element_manipulation.py: 96%
80 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1"""
2Element manipulation mixin for GLWidget - handles element transformations
3"""
5from typing import TYPE_CHECKING, Any, Optional, Tuple
7if TYPE_CHECKING:
8 from pyPhotoAlbum.models import BaseLayoutElement
9 from PyQt6.QtWidgets import QMainWindow
12class ElementManipulationMixin:
13 # Type hints for expected attributes from mixing class
14 selected_element: Optional["BaseLayoutElement"]
15 drag_start_pos: Optional[Tuple[float, float]]
16 drag_start_element_pos: Optional[Tuple[float, float]]
18 def window(self) -> "QMainWindow": # type: ignore[empty-body]
19 """Expected from QWidget"""
20 ...
22 def __init__(self, *args, **kwargs):
23 """
24 Initialize element manipulation mixin.
26 This mixin provides element transformation functionality including
27 resizing, rotating, moving elements, snapping support and cross-page
28 element transfers.
29 """
30 super().__init__(*args, **kwargs)
32 # Resize state
33 self.resize_handle: Optional[str] = None # 'nw', 'ne', 'sw', 'se'
34 self.resize_start_pos: Optional[Tuple[float, float]] = None
35 self.resize_start_size: Optional[Tuple[float, float]] = None
37 # Rotation state
38 self.rotation_mode: bool = False # Toggle between move/resize and rotation modes
39 self.rotation_start_angle: Optional[float] = None
40 self.rotation_snap_angle: int = 15 # Default snap angle in degrees
42 # Snap state tracking
43 self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None}
45 def _resize_element(self, dx: float, dy: float):
46 """
47 Resize the element based on the resize handle.
49 Args:
50 dx: Delta X in page-local coordinates
51 dy: Delta Y in page-local coordinates
52 """
53 if not self.selected_element or not self.resize_handle:
54 return
56 if not self.resize_start_pos or not self.resize_start_size:
57 return
59 # Get the snapping system from the element's parent page
60 main_window = self.window()
61 if not hasattr(self.selected_element, "_parent_page"):
62 self._resize_element_no_snap(dx, dy)
63 return
65 parent_page = self.selected_element._parent_page # type: ignore[attr-defined]
66 snap_sys = parent_page.layout.snapping_system
68 # Get page size
69 page_size = parent_page.layout.size
70 dpi = main_window.project.working_dpi # type: ignore[attr-defined]
72 # Apply snapping to resize
73 from pyPhotoAlbum.snapping import SnapResizeParams
75 params = SnapResizeParams(
76 position=self.resize_start_pos,
77 size=self.resize_start_size,
78 dx=dx,
79 dy=dy,
80 resize_handle=self.resize_handle,
81 page_size=page_size,
82 dpi=dpi,
83 project=main_window.project, # type: ignore[attr-defined]
84 )
85 new_pos, new_size = snap_sys.snap_resize(params)
87 # Apply the snapped values
88 self.selected_element.position = new_pos
89 self.selected_element.size = new_size
91 # Ensure minimum size
92 min_size = 20
93 w, h = self.selected_element.size
94 if w < min_size or h < min_size:
95 w = max(w, min_size)
96 h = max(h, min_size)
97 self.selected_element.size = (w, h)
99 def _resize_element_no_snap(self, dx: float, dy: float):
100 """
101 Resize element without snapping.
103 Args:
104 dx: Delta X in page-local coordinates
105 dy: Delta Y in page-local coordinates
106 """
107 if not self.resize_start_pos or not self.resize_start_size:
108 return
109 if self.selected_element is None:
110 return
112 start_x, start_y = self.resize_start_pos
113 start_w, start_h = self.resize_start_size
115 if self.resize_handle == "nw":
116 self.selected_element.position = (start_x + dx, start_y + dy)
117 self.selected_element.size = (start_w - dx, start_h - dy)
118 elif self.resize_handle == "ne":
119 self.selected_element.position = (start_x, start_y + dy)
120 self.selected_element.size = (start_w + dx, start_h - dy)
121 elif self.resize_handle == "sw":
122 self.selected_element.position = (start_x + dx, start_y)
123 self.selected_element.size = (start_w - dx, start_h + dy)
124 elif self.resize_handle == "se":
125 self.selected_element.size = (start_w + dx, start_h + dy)
127 # Ensure minimum size
128 min_size = 20
129 w, h = self.selected_element.size
130 if w < min_size:
131 self.selected_element.size = (min_size, h)
132 if h < min_size:
133 w, _ = self.selected_element.size
134 self.selected_element.size = (w, min_size)
136 def _transfer_element_to_page(
137 self, element, source_page, target_page, mouse_x: float, mouse_y: float, target_renderer
138 ):
139 """
140 Transfer an element from one page to another during drag operation.
142 Args:
143 element: The element to transfer
144 source_page: Source page object
145 target_page: Target page object
146 mouse_x: Current mouse X position in screen coordinates
147 mouse_y: Current mouse Y position in screen coordinates
148 target_renderer: PageRenderer for the target page
149 """
150 # Convert mouse position to target page coordinates
151 new_page_x, new_page_y = target_renderer.screen_to_page(mouse_x, mouse_y)
153 # Get element size
154 elem_w, elem_h = element.size
156 # Center the element on the mouse position
157 new_x = new_page_x - elem_w / 2
158 new_y = new_page_y - elem_h / 2
160 # Remove element from source page
161 if element in source_page.layout.elements:
162 source_page.layout.elements.remove(element)
163 print(f"Removed element from page {source_page.page_number}")
165 # Update element position to new page coordinates
166 element.position = (new_x, new_y)
168 # Add element to target page
169 target_page.layout.add_element(element)
171 # Update element's parent page reference
172 element._parent_page = target_page
173 element._page_renderer = target_renderer
175 # Update drag start position and element position for continued dragging
176 self.drag_start_pos = (mouse_x, mouse_y)
177 self.drag_start_element_pos = element.position
179 print(f"Transferred element to page {target_page.page_number} at ({new_x:.1f}, {new_y:.1f})")