1 """

2 OpenGL widget for pyPhotoAlbum rendering - refactored with mixins

3 """

4

5 from PyQt6.QtOpenGLWidgets import QOpenGLWidget

6 from PyQt6.QtCore import Qt

7 from pyPhotoAlbum.gl_imports import *

8

9 # Import all mixins

10 from pyPhotoAlbum.mixins.viewport import ViewportMixin

11 from pyPhotoAlbum.mixins.rendering import RenderingMixin

12 from pyPhotoAlbum.mixins.asset_path import AssetPathMixin

13 from pyPhotoAlbum.mixins.asset_drop import AssetDropMixin

14 from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin

15 from pyPhotoAlbum.mixins.image_pan import ImagePanMixin

16 from pyPhotoAlbum.mixins.element_manipulation import ElementManipulationMixin

17 from pyPhotoAlbum.mixins.element_selection import ElementSelectionMixin

18 from pyPhotoAlbum.mixins.mouse_interaction import MouseInteractionMixin

19 from pyPhotoAlbum.mixins.interaction_undo import UndoableInteractionMixin

20 from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin

21 from pyPhotoAlbum.mixins.keyboard_navigation import KeyboardNavigationMixin

22

23

24 class GLWidget(

25 AsyncLoadingMixin,

26 ViewportMixin,

27 RenderingMixin,

28 AssetPathMixin,

29 AssetDropMixin,

30 PageNavigationMixin,

31 ImagePanMixin,

32 ElementManipulationMixin,

33 ElementSelectionMixin,

34 MouseInteractionMixin,

35 UndoableInteractionMixin,

36 KeyboardNavigationMixin,

37 QOpenGLWidget,

38 ):

39 """OpenGL widget for pyPhotoAlbum rendering and user interaction

40

41 This widget orchestrates multiple mixins to provide:

42 - Async image loading (non-blocking)

43 - Viewport control (zoom, pan)

44 - Page rendering (OpenGL)

45 - Element selection and manipulation

46 - Mouse interaction handling

47 - Drag-and-drop asset management

48 - Image panning within frames

49 - Page navigation and ghost pages

50 - Undo/redo integration

51 """

52

53 def __init__(self, parent=None):

54 super().__init__(parent)

55

56 # Store reference to main window for accessing project

57 self._main_window = parent

58

59 # Initialize async loading system

60 self._init_async_loading()

61

62 # Set up OpenGL surface format with explicit double buffering

63 from PyQt6.QtGui import QSurfaceFormat

64

65 fmt = QSurfaceFormat()

66 fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer)

67 fmt.setSwapInterval(1) # Enable vsync

68 self.setFormat(fmt)

69

70 # Force full redraws to ensure viewport updates

71 self.setUpdateBehavior(QOpenGLWidget.UpdateBehavior.NoPartialUpdate)

72

73 # Enable mouse tracking and drag-drop

74 self.setMouseTracking(True)

75 self.setAcceptDrops(True)

76

77 # Enable keyboard focus

78 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)

79 self.setFocus()

80

81 # Enable gesture support for pinch-to-zoom

82 self.grabGesture(Qt.GestureType.PinchGesture)

83

84 # Track pinch gesture state

85 self._pinch_scale_factor = 1.0

86

87 def window(self):

88 """Override window() to return stored main_window reference.

89

90 This fixes the Qt widget hierarchy issue where window() returns None

91 because the GL widget is nested in container widgets.

92 """

93 return self._main_window if hasattr(self, "_main_window") else super().window()

94

95 def update(self):

96 """Override update to force immediate repaint"""

97 super().update()

98 # Force immediate processing of paint events

99 self.repaint()

100

101 def closeEvent(self, event):

102 """Handle widget close event."""

103 # Cleanup async loading

104 self._cleanup_async_loading()

105 super().closeEvent(event)

106

107 def _get_project_folder(self):

108 """Override AssetPathMixin to access project via main window."""

109 main_window = self.window()

110 if hasattr(main_window, "project") and main_window.project:

111 return getattr(main_window.project, "folder_path", None)

112 return None

113

114 def keyPressEvent(self, event):

115 """Handle key press events"""

116 if event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace:

117 if self.selected_element:

118 main_window = self.window()

119 if hasattr(main_window, "delete_selected_element"):

120 main_window.delete_selected_element()

121

122 elif event.key() == Qt.Key.Key_Escape:

123 self.selected_element = None

124 self.rotation_mode = False

125 self.update()

126

127 elif event.key() == Qt.Key.Key_Tab:

128 # Toggle rotation mode when an element is selected

129 if self.selected_element:

130 self.rotation_mode = not self.rotation_mode

131 main_window = self.window()

132 if hasattr(main_window, "show_status"):

133 mode_text = "Rotation Mode" if self.rotation_mode else "Move/Resize Mode"

134 main_window.show_status(f"Switched to {mode_text}", 2000)

135 print(f"Rotation mode: {self.rotation_mode}")

136 self.update()

137 event.accept()

138 else:

139 super().keyPressEvent(event)

140

141 elif event.key() == Qt.Key.Key_PageDown:

142 # Navigate to next page

143 self._navigate_to_next_page()

144 event.accept()

145

146 elif event.key() == Qt.Key.Key_PageUp:

147 # Navigate to previous page

148 self._navigate_to_previous_page()

149 event.accept()

150

151 elif event.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down, Qt.Key.Key_Left, Qt.Key.Key_Right):

152 # Arrow key handling

153 if self.selected_elements:

154 # Move selected elements

155 self._move_selected_elements_with_arrow_keys(event.key())

156 event.accept()

157 else:

158 # Move viewport

159 self._move_viewport_with_arrow_keys(event.key())

160 event.accept()

161

162 else:

163 super().keyPressEvent(event)

164

165 def event(self, event):

166 """Handle gesture events for pinch-to-zoom"""

167 from PyQt6.QtCore import QEvent, Qt as QtCore

168 from PyQt6.QtWidgets import QPinchGesture

169 from PyQt6.QtGui import QNativeGestureEvent

170

171 # Handle native touchpad gestures (Linux, macOS)

172 if event.type() == QEvent.Type.NativeGesture:

173 native_event = event

174 gesture_type = native_event.gestureType()

175

176 print(f"DEBUG: Native gesture detected - type: {gesture_type}")

177

178 # Check for zoom/pinch gesture

179 if gesture_type == QtCore.NativeGestureType.ZoomNativeGesture:

180 # Get zoom value (typically a delta around 0)

181 value = native_event.value()

182 print(f"DEBUG: Zoom value: {value}")

183

184 # Convert to scale factor (value is typically small, like -0.1 to 0.1)

185 # Positive value = zoom in, negative = zoom out

186 scale_factor = 1.0 + value

187

188 # Get the position of the gesture

189 pos = native_event.position()

190 mouse_x = pos.x()

191 mouse_y = pos.y()

192

193 self._apply_zoom_at_point(mouse_x, mouse_y, scale_factor)

194 return True

195

196 # Check for pan gesture (two-finger drag)

197 elif gesture_type == QtCore.NativeGestureType.PanNativeGesture:

198 # Get the pan delta

199 delta = native_event.delta()

200 dx = delta.x()

201 dy = delta.y()

202

203 print(f"DEBUG: Pan delta: dx={dx}, dy={dy}")

204

205 # Apply pan

206 self.pan_offset[0] += dx

207 self.pan_offset[1] += dy

208

209 # Clamp pan offset to content bounds

210 if hasattr(self, "clamp_pan_offset"):

211 self.clamp_pan_offset()

212

213 self.update()

214

215 # Update scrollbars if available

216 main_window = self.window()

217 if hasattr(main_window, "update_scrollbars"):

218 main_window.update_scrollbars()

219

220 return True

221

222 # Handle Qt gesture events (fallback for other platforms)

223 elif event.type() == QEvent.Type.Gesture:

224 print("DEBUG: Qt Gesture event detected")

225 gesture_event = event

226 pinch = gesture_event.gesture(Qt.GestureType.PinchGesture)

227

228 if pinch:

229 print(f"DEBUG: Pinch gesture detected - state: {pinch.state()}, scale: {pinch.totalScaleFactor()}")

230 self._handle_pinch_gesture(pinch)

231 return True

232

233 return super().event(event)

234

235 def _handle_pinch_gesture(self, pinch):

236 """Handle pinch gesture for zooming"""

237 from PyQt6.QtCore import Qt as QtCore

238

239 # Check gesture state

240 state = pinch.state()

241

242 if state == QtCore.GestureState.GestureStarted:

243 # Reset scale factor at gesture start

244 self._pinch_scale_factor = 1.0

245 return

246

247 elif state == QtCore.GestureState.GestureUpdated:

248 # Get current total scale factor

249 current_scale = pinch.totalScaleFactor()

250

251 # Calculate incremental change from last update

252 if current_scale > 0:

253 scale_change = current_scale / self._pinch_scale_factor

254 self._pinch_scale_factor = current_scale

255

256 # Get the center point of the pinch gesture

257 center_point = pinch.centerPoint()

258 mouse_x = center_point.x()

259 mouse_y = center_point.y()

260

261 # Calculate world coordinates at the pinch center

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

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

264

265 # Apply incremental zoom change

266 new_zoom = self.zoom_level * scale_change

267

268 # Clamp zoom level to reasonable bounds

269 if 0.1 <= new_zoom <= 5.0:

270 old_pan_x = self.pan_offset[0]

271 old_pan_y = self.pan_offset[1]

272

273 self.zoom_level = new_zoom

274

275 # Adjust pan offset to keep the pinch center point fixed

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

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

278

279 # If dragging, adjust drag_start_pos to account for pan_offset change

280 if (

281 hasattr(self, "is_dragging")

282 and self.is_dragging

283 and hasattr(self, "drag_start_pos")

284 and self.drag_start_pos

285 ):

286 pan_delta_x = self.pan_offset[0] - old_pan_x

287 pan_delta_y = self.pan_offset[1] - old_pan_y

288 self.drag_start_pos = (

289 self.drag_start_pos[0] + pan_delta_x,

290 self.drag_start_pos[1] + pan_delta_y,

291 )

292

293 # Clamp pan offset to content bounds

294 if hasattr(self, "clamp_pan_offset"):

295 self.clamp_pan_offset()

296

297 self.update()

298

299 # Update status bar

300 main_window = self.window()

301 if hasattr(main_window, "status_bar"):

302 main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)

303

304 # Update scrollbars if available

305 if hasattr(main_window, "update_scrollbars"):

306 main_window.update_scrollbars()

307

308 elif state == QtCore.GestureState.GestureFinished or state == QtCore.GestureState.GestureCanceled:

309 # Reset on gesture end

310 self._pinch_scale_factor = 1.0

311

312 def _apply_zoom_at_point(self, mouse_x, mouse_y, scale_factor):

313 """Apply zoom centered at a specific point"""

314 # Calculate world coordinates at the zoom center

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

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

317

318 # Apply zoom

319 new_zoom = self.zoom_level * scale_factor

320

321 # Clamp zoom level to reasonable bounds

322 if 0.1 <= new_zoom <= 5.0:

323 old_pan_x = self.pan_offset[0]

324 old_pan_y = self.pan_offset[1]

325

326 self.zoom_level = new_zoom

327

328 # Adjust pan offset to keep the zoom center point fixed

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

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

331

332 # If dragging, adjust drag_start_pos to account for pan_offset change

333 if (

334 hasattr(self, "is_dragging")

335 and self.is_dragging

336 and hasattr(self, "drag_start_pos")

337 and self.drag_start_pos

338 ):

339 pan_delta_x = self.pan_offset[0] - old_pan_x

340 pan_delta_y = self.pan_offset[1] - old_pan_y

341 self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)

342

343 # Clamp pan offset to content bounds

344 if hasattr(self, "clamp_pan_offset"):

345 self.clamp_pan_offset()

346

347 self.update()

348

349 # Update status bar

350 main_window = self.window()

351 if hasattr(main_window, "status_bar"):

352 main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)

353

354 # Update scrollbars if available

355 if hasattr(main_window, "update_scrollbars"):

356 main_window.update_scrollbars()