pyPhotoAlbum/pyPhotoAlbum/gl_widget.py
2026-01-01 17:47:58 +00:00

343 lines
13 KiB
Python

"""
OpenGL widget for pyPhotoAlbum rendering - refactored with mixins
"""
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from PyQt6.QtCore import Qt
from pyPhotoAlbum.gl_imports import *
# Import all mixins
from pyPhotoAlbum.mixins.viewport import ViewportMixin
from pyPhotoAlbum.mixins.rendering import RenderingMixin
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
from pyPhotoAlbum.mixins.asset_drop import AssetDropMixin
from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin
from pyPhotoAlbum.mixins.image_pan import ImagePanMixin
from pyPhotoAlbum.mixins.element_manipulation import ElementManipulationMixin
from pyPhotoAlbum.mixins.element_selection import ElementSelectionMixin
from pyPhotoAlbum.mixins.mouse_interaction import MouseInteractionMixin
from pyPhotoAlbum.mixins.interaction_undo import UndoableInteractionMixin
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
from pyPhotoAlbum.mixins.keyboard_navigation import KeyboardNavigationMixin
class GLWidget(
AsyncLoadingMixin,
ViewportMixin,
RenderingMixin,
AssetPathMixin,
AssetDropMixin,
PageNavigationMixin,
ImagePanMixin,
ElementManipulationMixin,
ElementSelectionMixin,
MouseInteractionMixin,
UndoableInteractionMixin,
KeyboardNavigationMixin,
QOpenGLWidget,
):
"""OpenGL widget for pyPhotoAlbum rendering and user interaction
This widget orchestrates multiple mixins to provide:
- Async image loading (non-blocking)
- Viewport control (zoom, pan)
- Page rendering (OpenGL)
- Element selection and manipulation
- Mouse interaction handling
- Drag-and-drop asset management
- Image panning within frames
- Page navigation and ghost pages
- Undo/redo integration
"""
def __init__(self, parent=None):
super().__init__(parent)
# Store reference to main window for accessing project
self._main_window = parent
# Initialize async loading system
self._init_async_loading()
# Set up OpenGL surface format with explicit double buffering
from PyQt6.QtGui import QSurfaceFormat
fmt = QSurfaceFormat()
fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer)
fmt.setSwapInterval(1) # Enable vsync
self.setFormat(fmt)
# Force full redraws to ensure viewport updates
self.setUpdateBehavior(QOpenGLWidget.UpdateBehavior.NoPartialUpdate)
# Enable mouse tracking and drag-drop
self.setMouseTracking(True)
self.setAcceptDrops(True)
# Enable keyboard focus
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setFocus()
# Enable gesture support for pinch-to-zoom
self.grabGesture(Qt.GestureType.PinchGesture)
# Track pinch gesture state
self._pinch_scale_factor = 1.0
def window(self):
"""Override window() to return stored main_window reference.
This fixes the Qt widget hierarchy issue where window() returns None
because the GL widget is nested in container widgets.
"""
return self._main_window if hasattr(self, '_main_window') else super().window()
def update(self):
"""Override update to force immediate repaint"""
super().update()
# Force immediate processing of paint events
self.repaint()
def closeEvent(self, event):
"""Handle widget close event."""
# Cleanup async loading
self._cleanup_async_loading()
super().closeEvent(event)
def _get_project_folder(self):
"""Override AssetPathMixin to access project via main window."""
main_window = self.window()
if hasattr(main_window, "project") and main_window.project:
return getattr(main_window.project, "folder_path", None)
return None
def keyPressEvent(self, event):
"""Handle key press events"""
if event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace:
if self.selected_element:
main_window = self.window()
if hasattr(main_window, "delete_selected_element"):
main_window.delete_selected_element()
elif event.key() == Qt.Key.Key_Escape:
self.selected_element = None
self.rotation_mode = False
self.update()
elif event.key() == Qt.Key.Key_Tab:
# Toggle rotation mode when an element is selected
if self.selected_element:
self.rotation_mode = not self.rotation_mode
main_window = self.window()
if hasattr(main_window, "show_status"):
mode_text = "Rotation Mode" if self.rotation_mode else "Move/Resize Mode"
main_window.show_status(f"Switched to {mode_text}", 2000)
print(f"Rotation mode: {self.rotation_mode}")
self.update()
event.accept()
else:
super().keyPressEvent(event)
elif event.key() == Qt.Key.Key_PageDown:
# Navigate to next page
self._navigate_to_next_page()
event.accept()
elif event.key() == Qt.Key.Key_PageUp:
# Navigate to previous page
self._navigate_to_previous_page()
event.accept()
elif event.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down, Qt.Key.Key_Left, Qt.Key.Key_Right):
# Arrow key handling
if self.selected_elements:
# Move selected elements
self._move_selected_elements_with_arrow_keys(event.key())
event.accept()
else:
# Move viewport
self._move_viewport_with_arrow_keys(event.key())
event.accept()
else:
super().keyPressEvent(event)
def event(self, event):
"""Handle gesture events for pinch-to-zoom"""
from PyQt6.QtCore import QEvent, Qt as QtCore
from PyQt6.QtWidgets import QPinchGesture
from PyQt6.QtGui import QNativeGestureEvent
# Handle native touchpad gestures (Linux, macOS)
if event.type() == QEvent.Type.NativeGesture:
native_event = event
gesture_type = native_event.gestureType()
print(f"DEBUG: Native gesture detected - type: {gesture_type}")
# Check for zoom/pinch gesture
if gesture_type == QtCore.NativeGestureType.ZoomNativeGesture:
# Get zoom value (typically a delta around 0)
value = native_event.value()
print(f"DEBUG: Zoom value: {value}")
# Convert to scale factor (value is typically small, like -0.1 to 0.1)
# Positive value = zoom in, negative = zoom out
scale_factor = 1.0 + value
# Get the position of the gesture
pos = native_event.position()
mouse_x = pos.x()
mouse_y = pos.y()
self._apply_zoom_at_point(mouse_x, mouse_y, scale_factor)
return True
# Check for pan gesture (two-finger drag)
elif gesture_type == QtCore.NativeGestureType.PanNativeGesture:
# Get the pan delta
delta = native_event.delta()
dx = delta.x()
dy = delta.y()
print(f"DEBUG: Pan delta: dx={dx}, dy={dy}")
# Apply pan
self.pan_offset[0] += dx
self.pan_offset[1] += dy
# Clamp pan offset to content bounds
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.update()
# Update scrollbars if available
main_window = self.window()
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
return True
# Handle Qt gesture events (fallback for other platforms)
elif event.type() == QEvent.Type.Gesture:
print("DEBUG: Qt Gesture event detected")
gesture_event = event
pinch = gesture_event.gesture(Qt.GestureType.PinchGesture)
if pinch:
print(f"DEBUG: Pinch gesture detected - state: {pinch.state()}, scale: {pinch.totalScaleFactor()}")
self._handle_pinch_gesture(pinch)
return True
return super().event(event)
def _handle_pinch_gesture(self, pinch):
"""Handle pinch gesture for zooming"""
from PyQt6.QtCore import Qt as QtCore
# Check gesture state
state = pinch.state()
if state == QtCore.GestureState.GestureStarted:
# Reset scale factor at gesture start
self._pinch_scale_factor = 1.0
return
elif state == QtCore.GestureState.GestureUpdated:
# Get current total scale factor
current_scale = pinch.totalScaleFactor()
# Calculate incremental change from last update
if current_scale > 0:
scale_change = current_scale / self._pinch_scale_factor
self._pinch_scale_factor = current_scale
# Get the center point of the pinch gesture
center_point = pinch.centerPoint()
mouse_x = center_point.x()
mouse_y = center_point.y()
# Calculate world coordinates at the pinch center
world_x = (mouse_x - self.pan_offset[0]) / self.zoom_level
world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
# Apply incremental zoom change
new_zoom = self.zoom_level * scale_change
# Clamp zoom level to reasonable bounds
if 0.1 <= new_zoom <= 5.0:
old_pan_x = self.pan_offset[0]
old_pan_y = self.pan_offset[1]
self.zoom_level = new_zoom
# Adjust pan offset to keep the pinch center point fixed
self.pan_offset[0] = mouse_x - world_x * self.zoom_level
self.pan_offset[1] = mouse_y - world_y * self.zoom_level
# If dragging, adjust drag_start_pos to account for pan_offset change
if hasattr(self, 'is_dragging') and self.is_dragging and hasattr(self, 'drag_start_pos') and self.drag_start_pos:
pan_delta_x = self.pan_offset[0] - old_pan_x
pan_delta_y = self.pan_offset[1] - old_pan_y
self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
# Clamp pan offset to content bounds
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.update()
# Update status bar
main_window = self.window()
if hasattr(main_window, "status_bar"):
main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)
# Update scrollbars if available
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
elif state == QtCore.GestureState.GestureFinished or state == QtCore.GestureState.GestureCanceled:
# Reset on gesture end
self._pinch_scale_factor = 1.0
def _apply_zoom_at_point(self, mouse_x, mouse_y, scale_factor):
"""Apply zoom centered at a specific point"""
# Calculate world coordinates at the zoom center
world_x = (mouse_x - self.pan_offset[0]) / self.zoom_level
world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
# Apply zoom
new_zoom = self.zoom_level * scale_factor
# Clamp zoom level to reasonable bounds
if 0.1 <= new_zoom <= 5.0:
old_pan_x = self.pan_offset[0]
old_pan_y = self.pan_offset[1]
self.zoom_level = new_zoom
# Adjust pan offset to keep the zoom center point fixed
self.pan_offset[0] = mouse_x - world_x * self.zoom_level
self.pan_offset[1] = mouse_y - world_y * self.zoom_level
# If dragging, adjust drag_start_pos to account for pan_offset change
if hasattr(self, 'is_dragging') and self.is_dragging and hasattr(self, 'drag_start_pos') and self.drag_start_pos:
pan_delta_x = self.pan_offset[0] - old_pan_x
pan_delta_y = self.pan_offset[1] - old_pan_y
self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
# Clamp pan offset to content bounds
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.update()
# Update status bar
main_window = self.window()
if hasattr(main_window, "status_bar"):
main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)
# Update scrollbars if available
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()