Coverage for pyPhotoAlbum/mixins/mouse_interaction.py: 97%
253 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"""
2Mouse interaction mixin for GLWidget - coordinates all mouse events
3"""
5import math
6from typing import TYPE_CHECKING, Any, Optional, Set
8from PyQt6.QtCore import Qt
9from PyQt6.QtGui import QCursor
10from pyPhotoAlbum.models import ImageData, BaseLayoutElement
12if TYPE_CHECKING:
13 from PyQt6.QtWidgets import QMainWindow
16class MouseInteractionMixin:
17 """
18 Mixin providing mouse event handling and coordination.
20 This mixin routes mouse events to appropriate other mixins based on
21 the current interaction state.
22 """
24 # Type declarations for attributes provided by other mixins/base classes
25 selected_elements: Set[BaseLayoutElement]
26 selected_element: Optional[BaseLayoutElement]
27 rotation_mode: bool
28 rotation_snap_angle: int
29 rotation_start_angle: Optional[float]
30 pan_offset: list
31 zoom_level: float
32 image_pan_mode: bool
33 current_page_index: int
34 _page_renderers: list
35 resize_start_pos: Optional[Any]
36 resize_start_size: Optional[Any]
38 if TYPE_CHECKING:
39 def window(self) -> "QMainWindow": ...
40 def update(self) -> None: ...
41 def setCursor(self, cursor: Any) -> None: ...
42 def setFocus(self, *args: Any) -> None: ...
43 def _begin_rotate(self, element: Any) -> None: ...
44 def _begin_resize(self, element: Any) -> None: ...
45 def _begin_image_pan(self, element: Any) -> None: ...
46 def _begin_move(self, element: Any) -> None: ...
47 def _end_interaction(self) -> None: ...
48 def _resize_element(self, dx: float, dy: float) -> None: ...
49 def _get_page_at(self, x: float, y: float) -> Any: ...
50 def _get_element_at(self, x: float, y: float) -> Optional[BaseLayoutElement]: ...
51 def _get_resize_handle_at(self, x: float, y: float) -> Optional[str]: ...
52 def _check_ghost_page_click(self, x: float, y: float) -> bool: ...
53 def _transfer_element_to_page(self, element: Any, source: Any, target: Any, x: float, y: float, renderer: Any) -> None: ...
54 def _handle_image_pan_move(self, x: float, y: float, element: Any) -> None: ...
55 def _update_page_status(self, x: float, y: float) -> None: ...
57 def __init__(self, *args, **kwargs):
58 super().__init__(*args, **kwargs)
60 # Mouse interaction state
61 self.drag_start_pos = None
62 self.drag_start_element_pos = None
63 self.is_dragging = False
64 self.is_panning = False
66 def _handle_rotation_start(self, x: float, y: float):
67 """Start rotation interaction for selected element."""
68 assert self.selected_element is not None
69 self._begin_rotate(self.selected_element)
70 self.drag_start_pos = (x, y)
71 self.rotation_start_angle = self.selected_element.rotation
72 self.is_dragging = True
74 def _handle_resize_start(self, x: float, y: float, handle):
75 """Start resize interaction for selected element."""
76 assert self.selected_element is not None
77 self._begin_resize(self.selected_element)
78 self.resize_handle = handle
79 self.drag_start_pos = (x, y)
80 self.resize_start_pos = self.selected_element.position
81 self.resize_start_size = self.selected_element.size
82 self.is_dragging = True
84 def _handle_image_pan_start(self, x: float, y: float, element):
85 """Start image pan mode for an ImageData element."""
86 self.selected_elements = {element}
87 self.drag_start_pos = (x, y)
88 self.image_pan_mode = True
89 self.image_pan_start_crop = element.crop_info
90 self._begin_image_pan(element)
91 self.is_dragging = True
92 self.setCursor(Qt.CursorShape.SizeAllCursor)
94 def _handle_multi_select(self, element):
95 """Toggle element in multi-selection."""
96 if element in self.selected_elements:
97 self.selected_elements.remove(element)
98 else:
99 self.selected_elements.add(element)
101 def _handle_element_drag_start(self, x: float, y: float, element):
102 """Start dragging an element."""
103 self.selected_elements = {element}
104 self.drag_start_pos = (x, y)
105 self.drag_start_element_pos = element.position
106 if not self.rotation_mode:
107 self._begin_move(element)
108 self.is_dragging = True
110 def mousePressEvent(self, event):
111 """Handle mouse press events"""
112 self.setFocus()
114 if event.button() == Qt.MouseButton.LeftButton:
115 self._handle_left_click(event)
116 elif event.button() == Qt.MouseButton.MiddleButton:
117 self.is_panning = True
118 self.drag_start_pos = (event.position().x(), event.position().y())
119 self.setCursor(Qt.CursorShape.ClosedHandCursor)
121 def _handle_left_click(self, event):
122 """Handle left mouse button click."""
123 x, y = event.position().x(), event.position().y()
124 ctrl_pressed = event.modifiers() & Qt.KeyboardModifier.ControlModifier
125 shift_pressed = event.modifiers() & Qt.KeyboardModifier.ShiftModifier
127 # Check if clicking on ghost page button
128 if self._check_ghost_page_click(x, y):
129 return
131 # Update current_page_index based on where user clicked
132 page, page_index, renderer = self._get_page_at(x, y)
133 if page_index >= 0:
134 self.current_page_index = page_index
136 # Handle interaction with already-selected element
137 if len(self.selected_elements) == 1 and self.selected_element:
138 if self.rotation_mode:
139 self._handle_rotation_start(x, y)
140 return
141 else:
142 handle = self._get_resize_handle_at(x, y)
143 if handle:
144 self._handle_resize_start(x, y, handle)
145 return
147 # Handle click on element
148 element = self._get_element_at(x, y)
149 if element:
150 if ctrl_pressed and isinstance(element, ImageData) and not self.rotation_mode:
151 self._handle_image_pan_start(x, y, element)
152 elif ctrl_pressed or shift_pressed:
153 self._handle_multi_select(element)
154 else:
155 self._handle_element_drag_start(x, y, element)
156 else:
157 if not ctrl_pressed:
158 self.selected_elements.clear()
160 self.update()
162 def _handle_canvas_pan(self, x: float, y: float):
163 """Handle canvas panning with middle mouse button."""
164 dx = x - self.drag_start_pos[0]
165 dy = y - self.drag_start_pos[1]
167 self.pan_offset[0] += dx
168 self.pan_offset[1] += dy
170 if hasattr(self, "clamp_pan_offset"):
171 self.clamp_pan_offset()
173 self.drag_start_pos = (x, y)
174 self.update()
176 main_window = self.window()
177 if hasattr(main_window, "update_scrollbars"):
178 main_window.update_scrollbars()
180 def _handle_rotation_move(self, x: float, y: float):
181 """Handle element rotation during drag."""
182 if self.selected_element is None or not hasattr(self.selected_element, "_page_renderer"):
183 return
185 renderer = self.selected_element._page_renderer # type: ignore[attr-defined]
186 elem_x, elem_y = self.selected_element.position
187 elem_w, elem_h = self.selected_element.size
189 center_page_x = elem_x + elem_w / 2
190 center_page_y = elem_y + elem_h / 2
191 screen_center_x, screen_center_y = renderer.page_to_screen(center_page_x, center_page_y)
193 dx = x - screen_center_x
194 dy = y - screen_center_y
195 angle = math.degrees(math.atan2(dy, dx))
196 angle = round(angle / self.rotation_snap_angle) * self.rotation_snap_angle
197 angle = angle % 360
199 self.selected_element.rotation = angle
201 main_window = self.window()
202 if hasattr(main_window, "show_status"):
203 main_window.show_status(f"Rotation: {angle:.1f}°", 100)
205 def _handle_resize_move(self, x: float, y: float):
206 """Handle element resize during drag."""
207 screen_dx = x - self.drag_start_pos[0]
208 screen_dy = y - self.drag_start_pos[1]
210 total_dx = screen_dx / self.zoom_level
211 total_dy = screen_dy / self.zoom_level
213 self._resize_element(total_dx, total_dy)
215 def _handle_element_move(self, x: float, y: float):
216 """Handle element movement during drag, including page transfer."""
217 assert self.selected_element is not None
218 current_page, current_page_index, current_renderer = self._get_page_at(x, y)
220 if current_page and hasattr(self.selected_element, "_parent_page"):
221 source_page = self.selected_element._parent_page # type: ignore[attr-defined]
223 if current_page is not source_page:
224 self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer)
225 else:
226 self._move_element_within_page(x, y, source_page)
227 else:
228 # No page context - simple move without snapping
229 total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
230 total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
232 new_x = self.drag_start_element_pos[0] + total_dx
233 new_y = self.drag_start_element_pos[1] + total_dy
235 self.selected_element.position = (new_x, new_y)
237 def _move_element_within_page(self, x: float, y: float, page):
238 """Move element within its current page with snapping."""
239 total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
240 total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
242 new_x = self.drag_start_element_pos[0] + total_dx
243 new_y = self.drag_start_element_pos[1] + total_dy
245 assert self.selected_element is not None
246 main_window = self.window()
247 snap_sys = page.layout.snapping_system
248 page_size = page.layout.size
249 dpi = main_window.project.working_dpi # type: ignore[attr-defined]
251 snapped_pos = snap_sys.snap_position(
252 position=(new_x, new_y),
253 size=self.selected_element.size,
254 page_size=page_size,
255 dpi=dpi,
256 project=main_window.project, # type: ignore[attr-defined]
257 )
259 self.selected_element.position = snapped_pos
261 def mouseMoveEvent(self, event):
262 """Handle mouse move events"""
263 x, y = event.position().x(), event.position().y()
265 self._update_page_status(x, y)
267 # Canvas panning (middle mouse button)
268 if self.is_panning and self.drag_start_pos:
269 self._handle_canvas_pan(x, y)
270 return
272 if not self.is_dragging or not self.drag_start_pos:
273 return
275 if not self.selected_element:
276 return
278 # Dispatch to appropriate handler based on interaction mode
279 if self.image_pan_mode:
280 self._handle_image_pan_move(x, y, self.selected_element)
281 elif self.rotation_mode:
282 self._handle_rotation_move(x, y)
283 elif self.resize_handle:
284 self._handle_resize_move(x, y)
285 else:
286 self._handle_element_move(x, y)
288 self.update()
290 def mouseReleaseEvent(self, event):
291 """Handle mouse release events"""
292 if event.button() == Qt.MouseButton.LeftButton:
293 self._end_interaction()
295 self.is_dragging = False
296 self.drag_start_pos = None
297 self.drag_start_element_pos = None
298 self.resize_handle = None
299 self.rotation_start_angle = None
300 self.image_pan_mode = False
301 self.image_pan_start_crop = None
302 self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None}
303 self.setCursor(Qt.CursorShape.ArrowCursor)
305 elif event.button() == Qt.MouseButton.MiddleButton:
306 self.is_panning = False
307 self.drag_start_pos = None
308 self.setCursor(Qt.CursorShape.ArrowCursor)
310 def mouseDoubleClickEvent(self, event):
311 """Handle mouse double-click events"""
312 if event.button() == Qt.MouseButton.LeftButton:
313 x, y = event.position().x(), event.position().y()
314 element = self._get_element_at(x, y)
316 from pyPhotoAlbum.models import TextBoxData
318 if isinstance(element, TextBoxData):
319 self._edit_text_element(element)
320 return
322 super().mouseDoubleClickEvent(event)
324 def wheelEvent(self, event):
325 """Handle mouse wheel events for scrolling or zooming (with Ctrl)"""
326 delta = event.angleDelta().y()
327 ctrl_pressed = event.modifiers() & Qt.KeyboardModifier.ControlModifier
329 if ctrl_pressed:
330 # Ctrl + Wheel: Zoom centered on mouse position
331 mouse_x = event.position().x()
332 mouse_y = event.position().y()
334 world_x = (mouse_x - self.pan_offset[0]) / self.zoom_level
335 world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
337 zoom_factor = 1.1 if delta > 0 else 0.9
338 new_zoom = self.zoom_level * zoom_factor
340 if 0.1 <= new_zoom <= 5.0:
341 old_zoom = self.zoom_level
342 old_pan_x = self.pan_offset[0]
343 old_pan_y = self.pan_offset[1]
345 self.zoom_level = new_zoom
347 self.pan_offset[0] = mouse_x - world_x * self.zoom_level
348 self.pan_offset[1] = mouse_y - world_y * self.zoom_level
350 # If dragging, adjust drag_start_pos to account for pan_offset change
351 if self.is_dragging and self.drag_start_pos:
352 pan_delta_x = self.pan_offset[0] - old_pan_x
353 pan_delta_y = self.pan_offset[1] - old_pan_y
354 self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
356 # Clamp pan offset to content bounds
357 if hasattr(self, "clamp_pan_offset"):
358 self.clamp_pan_offset()
360 self.update()
362 main_window = self.window()
363 if hasattr(main_window, "status_bar"):
364 main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)
366 # Update scrollbars if available
367 if hasattr(main_window, "update_scrollbars"):
368 main_window.update_scrollbars()
369 else:
370 # Regular wheel: Two-finger scroll (vertical and horizontal)
371 delta_x = event.angleDelta().x()
372 delta_y = event.angleDelta().y()
374 scroll_amount_x = delta_x * 0.5
375 scroll_amount_y = delta_y * 0.5
377 old_pan_x = self.pan_offset[0]
378 old_pan_y = self.pan_offset[1]
380 self.pan_offset[0] += scroll_amount_x
381 self.pan_offset[1] += scroll_amount_y
383 # Clamp pan offset to content bounds
384 if hasattr(self, "clamp_pan_offset"):
385 self.clamp_pan_offset()
387 # If dragging, adjust drag_start_pos to account for pan_offset change
388 if self.is_dragging and self.drag_start_pos:
389 pan_delta_x = self.pan_offset[0] - old_pan_x
390 pan_delta_y = self.pan_offset[1] - old_pan_y
391 self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
393 self.update()
395 # Update scrollbars if available
396 main_window = self.window()
397 if hasattr(main_window, "update_scrollbars"):
398 main_window.update_scrollbars()
400 def _edit_text_element(self, text_element):
401 """Open dialog to edit text element"""
402 from pyPhotoAlbum.text_edit_dialog import TextEditDialog
404 dialog = TextEditDialog(text_element, self)
405 if dialog.exec() == TextEditDialog.DialogCode.Accepted:
406 values = dialog.get_values()
408 text_element.text_content = values["text_content"]
409 text_element.font_settings = values["font_settings"]
410 text_element.alignment = values["alignment"]
412 self.update()
414 print(f"Updated text element: {values['text_content'][:50]}...")