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

1""" 

2Mouse interaction mixin for GLWidget - coordinates all mouse events 

3""" 

4 

5import math 

6from typing import TYPE_CHECKING, Any, Optional, Set 

7 

8from PyQt6.QtCore import Qt 

9from PyQt6.QtGui import QCursor 

10from pyPhotoAlbum.models import ImageData, BaseLayoutElement 

11 

12if TYPE_CHECKING: 

13 from PyQt6.QtWidgets import QMainWindow 

14 

15 

16class MouseInteractionMixin: 

17 """ 

18 Mixin providing mouse event handling and coordination. 

19 

20 This mixin routes mouse events to appropriate other mixins based on 

21 the current interaction state. 

22 """ 

23 

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] 

37 

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: ... 

56 

57 def __init__(self, *args, **kwargs): 

58 super().__init__(*args, **kwargs) 

59 

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 

65 

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 

73 

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 

83 

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) 

93 

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) 

100 

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 

109 

110 def mousePressEvent(self, event): 

111 """Handle mouse press events""" 

112 self.setFocus() 

113 

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) 

120 

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 

126 

127 # Check if clicking on ghost page button 

128 if self._check_ghost_page_click(x, y): 

129 return 

130 

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 

135 

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 

146 

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() 

159 

160 self.update() 

161 

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] 

166 

167 self.pan_offset[0] += dx 

168 self.pan_offset[1] += dy 

169 

170 if hasattr(self, "clamp_pan_offset"): 

171 self.clamp_pan_offset() 

172 

173 self.drag_start_pos = (x, y) 

174 self.update() 

175 

176 main_window = self.window() 

177 if hasattr(main_window, "update_scrollbars"): 

178 main_window.update_scrollbars() 

179 

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 

184 

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 

188 

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) 

192 

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 

198 

199 self.selected_element.rotation = angle 

200 

201 main_window = self.window() 

202 if hasattr(main_window, "show_status"): 

203 main_window.show_status(f"Rotation: {angle:.1f}°", 100) 

204 

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] 

209 

210 total_dx = screen_dx / self.zoom_level 

211 total_dy = screen_dy / self.zoom_level 

212 

213 self._resize_element(total_dx, total_dy) 

214 

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) 

219 

220 if current_page and hasattr(self.selected_element, "_parent_page"): 

221 source_page = self.selected_element._parent_page # type: ignore[attr-defined] 

222 

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 

231 

232 new_x = self.drag_start_element_pos[0] + total_dx 

233 new_y = self.drag_start_element_pos[1] + total_dy 

234 

235 self.selected_element.position = (new_x, new_y) 

236 

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 

241 

242 new_x = self.drag_start_element_pos[0] + total_dx 

243 new_y = self.drag_start_element_pos[1] + total_dy 

244 

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] 

250 

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 ) 

258 

259 self.selected_element.position = snapped_pos 

260 

261 def mouseMoveEvent(self, event): 

262 """Handle mouse move events""" 

263 x, y = event.position().x(), event.position().y() 

264 

265 self._update_page_status(x, y) 

266 

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 

271 

272 if not self.is_dragging or not self.drag_start_pos: 

273 return 

274 

275 if not self.selected_element: 

276 return 

277 

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) 

287 

288 self.update() 

289 

290 def mouseReleaseEvent(self, event): 

291 """Handle mouse release events""" 

292 if event.button() == Qt.MouseButton.LeftButton: 

293 self._end_interaction() 

294 

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) 

304 

305 elif event.button() == Qt.MouseButton.MiddleButton: 

306 self.is_panning = False 

307 self.drag_start_pos = None 

308 self.setCursor(Qt.CursorShape.ArrowCursor) 

309 

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) 

315 

316 from pyPhotoAlbum.models import TextBoxData 

317 

318 if isinstance(element, TextBoxData): 

319 self._edit_text_element(element) 

320 return 

321 

322 super().mouseDoubleClickEvent(event) 

323 

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 

328 

329 if ctrl_pressed: 

330 # Ctrl + Wheel: Zoom centered on mouse position 

331 mouse_x = event.position().x() 

332 mouse_y = event.position().y() 

333 

334 world_x = (mouse_x - self.pan_offset[0]) / self.zoom_level 

335 world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level 

336 

337 zoom_factor = 1.1 if delta > 0 else 0.9 

338 new_zoom = self.zoom_level * zoom_factor 

339 

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] 

344 

345 self.zoom_level = new_zoom 

346 

347 self.pan_offset[0] = mouse_x - world_x * self.zoom_level 

348 self.pan_offset[1] = mouse_y - world_y * self.zoom_level 

349 

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) 

355 

356 # Clamp pan offset to content bounds 

357 if hasattr(self, "clamp_pan_offset"): 

358 self.clamp_pan_offset() 

359 

360 self.update() 

361 

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) 

365 

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() 

373 

374 scroll_amount_x = delta_x * 0.5 

375 scroll_amount_y = delta_y * 0.5 

376 

377 old_pan_x = self.pan_offset[0] 

378 old_pan_y = self.pan_offset[1] 

379 

380 self.pan_offset[0] += scroll_amount_x 

381 self.pan_offset[1] += scroll_amount_y 

382 

383 # Clamp pan offset to content bounds 

384 if hasattr(self, "clamp_pan_offset"): 

385 self.clamp_pan_offset() 

386 

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) 

392 

393 self.update() 

394 

395 # Update scrollbars if available 

396 main_window = self.window() 

397 if hasattr(main_window, "update_scrollbars"): 

398 main_window.update_scrollbars() 

399 

400 def _edit_text_element(self, text_element): 

401 """Open dialog to edit text element""" 

402 from pyPhotoAlbum.text_edit_dialog import TextEditDialog 

403 

404 dialog = TextEditDialog(text_element, self) 

405 if dialog.exec() == TextEditDialog.DialogCode.Accepted: 

406 values = dialog.get_values() 

407 

408 text_element.text_content = values["text_content"] 

409 text_element.font_settings = values["font_settings"] 

410 text_element.alignment = values["alignment"] 

411 

412 self.update() 

413 

414 print(f"Updated text element: {values['text_content'][:50]}...")