Coverage for pyPhotoAlbum/gl_widget.py: 37%

178 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-20 12:55 +0000

1""" 

2OpenGL widget for pyPhotoAlbum rendering - refactored with mixins 

3""" 

4 

5from PyQt6.QtOpenGLWidgets import QOpenGLWidget 

6from PyQt6.QtCore import Qt 

7from pyPhotoAlbum.gl_imports import * 

8 

9# Import all mixins 

10from pyPhotoAlbum.mixins.viewport import ViewportMixin 

11from pyPhotoAlbum.mixins.rendering import RenderingMixin 

12from pyPhotoAlbum.mixins.asset_path import AssetPathMixin 

13from pyPhotoAlbum.mixins.asset_drop import AssetDropMixin 

14from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin 

15from pyPhotoAlbum.mixins.image_pan import ImagePanMixin 

16from pyPhotoAlbum.mixins.element_manipulation import ElementManipulationMixin 

17from pyPhotoAlbum.mixins.element_selection import ElementSelectionMixin 

18from pyPhotoAlbum.mixins.mouse_interaction import MouseInteractionMixin 

19from pyPhotoAlbum.mixins.interaction_undo import UndoableInteractionMixin 

20from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin 

21from pyPhotoAlbum.mixins.keyboard_navigation import KeyboardNavigationMixin 

22 

23 

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