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
« 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"""
5from PyQt6.QtOpenGLWidgets import QOpenGLWidget
6from PyQt6.QtCore import Qt
7from pyPhotoAlbum.gl_imports import *
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
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
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 """
53 def __init__(self, parent=None):
54 super().__init__(parent)
56 # Store reference to main window for accessing project
57 self._main_window = parent
59 # Initialize async loading system
60 self._init_async_loading()
62 # Set up OpenGL surface format with explicit double buffering
63 from PyQt6.QtGui import QSurfaceFormat
65 fmt = QSurfaceFormat()
66 fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer)
67 fmt.setSwapInterval(1) # Enable vsync
68 self.setFormat(fmt)
70 # Force full redraws to ensure viewport updates
71 self.setUpdateBehavior(QOpenGLWidget.UpdateBehavior.NoPartialUpdate)
73 # Enable mouse tracking and drag-drop
74 self.setMouseTracking(True)
75 self.setAcceptDrops(True)
77 # Enable keyboard focus
78 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
79 self.setFocus()
81 # Enable gesture support for pinch-to-zoom
82 self.grabGesture(Qt.GestureType.PinchGesture)
84 # Track pinch gesture state
85 self._pinch_scale_factor = 1.0
87 def window(self):
88 """Override window() to return stored main_window reference.
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()
95 def update(self):
96 """Override update to force immediate repaint"""
97 super().update()
98 # Force immediate processing of paint events
99 self.repaint()
101 def closeEvent(self, event):
102 """Handle widget close event."""
103 # Cleanup async loading
104 self._cleanup_async_loading()
105 super().closeEvent(event)
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
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()
122 elif event.key() == Qt.Key.Key_Escape:
123 self.selected_element = None
124 self.rotation_mode = False
125 self.update()
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)
141 elif event.key() == Qt.Key.Key_PageDown:
142 # Navigate to next page
143 self._navigate_to_next_page()
144 event.accept()
146 elif event.key() == Qt.Key.Key_PageUp:
147 # Navigate to previous page
148 self._navigate_to_previous_page()
149 event.accept()
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()
162 else:
163 super().keyPressEvent(event)
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
171 # Handle native touchpad gestures (Linux, macOS)
172 if event.type() == QEvent.Type.NativeGesture:
173 native_event = event
174 gesture_type = native_event.gestureType()
176 print(f"DEBUG: Native gesture detected - type: {gesture_type}")
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}")
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
188 # Get the position of the gesture
189 pos = native_event.position()
190 mouse_x = pos.x()
191 mouse_y = pos.y()
193 self._apply_zoom_at_point(mouse_x, mouse_y, scale_factor)
194 return True
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()
203 print(f"DEBUG: Pan delta: dx={dx}, dy={dy}")
205 # Apply pan
206 self.pan_offset[0] += dx
207 self.pan_offset[1] += dy
209 # Clamp pan offset to content bounds
210 if hasattr(self, "clamp_pan_offset"):
211 self.clamp_pan_offset()
213 self.update()
215 # Update scrollbars if available
216 main_window = self.window()
217 if hasattr(main_window, "update_scrollbars"):
218 main_window.update_scrollbars()
220 return True
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)
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
233 return super().event(event)
235 def _handle_pinch_gesture(self, pinch):
236 """Handle pinch gesture for zooming"""
237 from PyQt6.QtCore import Qt as QtCore
239 # Check gesture state
240 state = pinch.state()
242 if state == QtCore.GestureState.GestureStarted:
243 # Reset scale factor at gesture start
244 self._pinch_scale_factor = 1.0
245 return
247 elif state == QtCore.GestureState.GestureUpdated:
248 # Get current total scale factor
249 current_scale = pinch.totalScaleFactor()
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
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()
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
265 # Apply incremental zoom change
266 new_zoom = self.zoom_level * scale_change
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]
273 self.zoom_level = new_zoom
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
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 )
293 # Clamp pan offset to content bounds
294 if hasattr(self, "clamp_pan_offset"):
295 self.clamp_pan_offset()
297 self.update()
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)
304 # Update scrollbars if available
305 if hasattr(main_window, "update_scrollbars"):
306 main_window.update_scrollbars()
308 elif state == QtCore.GestureState.GestureFinished or state == QtCore.GestureState.GestureCanceled:
309 # Reset on gesture end
310 self._pinch_scale_factor = 1.0
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
318 # Apply zoom
319 new_zoom = self.zoom_level * scale_factor
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]
326 self.zoom_level = new_zoom
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
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)
343 # Clamp pan offset to content bounds
344 if hasattr(self, "clamp_pan_offset"):
345 self.clamp_pan_offset()
347 self.update()
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)
354 # Update scrollbars if available
355 if hasattr(main_window, "update_scrollbars"):
356 main_window.update_scrollbars()