1 """

2 Mouse interaction mixin for GLWidget - coordinates all mouse events

3 """

4

5 import math

6 from typing import TYPE_CHECKING, Any, Optional, Set

7

8 from PyQt6.QtCore import Qt

9 from PyQt6.QtGui import QCursor

10 from pyPhotoAlbum.models import ImageData, BaseLayoutElement

11

12 if TYPE_CHECKING:

13 from PyQt6.QtWidgets import QMainWindow

14

15

16 class 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]}...")